New Features
Enrolling a Kiosk Is a Scan, and a Lost Link Is One Click
Enrolling a store tablet meant reading a long URL off an admin’s laptop and typing it into the tablet by hand. Worse, a link that was lost or left to expire couldn’t be recovered at all — only its hash is stored — so the only way forward was to revoke the kiosk and enroll it again, re-entering its name, location, app and idle timeout.- The enroll link is a QR code - The enroll dialog now draws the link as a QR code for the tablet’s camera. The code is rendered in the browser rather than fetched from a hosted QR service, so the enroll token never leaves the deployment.
- A lost link is replaced, not re-shown - A New link action appears under a kiosk that is waiting for its tablet or whose link expired. It issues a fresh link, kills the old one in the same step, and restarts the 24-hour window — keeping the token hash-only while making a lost link cost one click instead of the whole kiosk.
- A bound kiosk is refused - Once a tablet is bound, or the kiosk has been revoked, a new link is declined. Moving a tablet to another device stays revoke-and-enroll, so a takeover always leaves a revoke in the audit trail.
- “Enrol” is now “Enroll” - The spelling is corrected throughout Crew settings, the tablet’s own enroll page and the worker-enrollment error messages. API fields, routes and stored columns keep the original spelling, because those are contracts.
On a local development box the QR code encodes a loopback address that a real tablet can’t reach. On a deployed environment it carries the real domain.
A Kiosk Shows Crew Sign-In and Nothing Else
A worker walking up to a shared tablet saw five ways to sign in — crew PIN, magic link, GitHub, Google and dev sign-in — and only one of them was theirs.- Only the PIN pad on an enrolled kiosk - A kiosk browser now shows the crew sign-in form alone, with a small Sign in as an admin link beneath it. The link appears only when at least one account method is configured.
- Account sign-in is one tap away, not removed - The link opens a dialog holding every account option that was there before: magic link, GitHub, Google, Okta, and dev sign-in where enabled. This is presentation, not an access boundary — a manager can still sign in with their account from the tablet, which matters because a kiosk’s cookie lasts a year and would otherwise have to be cleared by hand.
- Nothing flashes before the page settles - The login page now waits for the kiosk check to answer on every browser, so a tablet no longer briefly renders exactly the options it is meant to hide. If the check fails the page falls back to the ordinary layout rather than hanging, and a revoked tablet gets the ordinary login page.
- Other browsers are unchanged - A normal browser sees the login page exactly as before, including the “Crew member?” hint.
A Crew Name Picker Built for a Whole Store Roster
A store roster of 20–26 people is normal, and the picker was a fixed 288-pixel box holding two columns in whatever order the server returned — on a 1280×800 tablet about four of twenty names were visible without scrolling. The picker is now sized for a crew.- The names fill the screen - Two columns on a phone, three on an upright tablet, four in landscape, running as one continuous A–Z grid. The first name under each letter carries a small grey letter, and a letter rail beside the list jumps to it. A store whose names all share one letter gets no rail.
- Sorted by the name on the tile - The list is ordered by the name a worker actually reads, not by the internal identifier the server happens to return rows in.
- A store’s crew can no longer fall off the end of the roster read - The roster read fetched up to 200 PIN holders across the whole organization and only then narrowed them to the kiosk’s store, so past 200 credentials a store’s own people were simply missing from the picker — no error, no warning. The store’s people are now resolved first and the cap counts them. The read still fails closed: an error or an empty result answers with nobody rather than with the whole organization.
- “On this tablet recently” - The last four people who signed in on this kiosk appear first. Only identifiers are kept, never a name or a PIN, they stay on the device, and they are written only after a successful sign-in — so somebody who has left the roster drops out of the row. The row appears once a store has twelve people and is hidden while searching.
- “Find your name” - A search box appears past 30 people, ignoring case and accents.
- The PIN pad appears once a name is picked - As a side panel in landscape or a bottom sheet otherwise, carrying the name, PIN dots, a 0–9 keypad with Delete and Clear, and “Not you? Choose another name”. Hardware and on-screen keyboards still type into it, focus moves into the step, and Escape returns to the name. Sign in as an admin moves to the top corner.
- Every existing message is unchanged - Wrong PIN, too many attempts, the empty-roster “Nobody is set up to sign in at … yet”, and the typed-identifier fallback all read exactly as before. The ordinary
/loginpage is untouched.
Store Tablets Open the Right App, and Stay Editable
Three defaults and a missing route made a kiosk harder to set up than it needed to be, and impossible to adjust without taking the tablet out of service.- A new kiosk opens the crew app - Opens defaulted to Organization home, and a tablet enrolled that way signed crew in to “This kiosk has no app to open — ask your manager.” With one published app it is now preselected; with several the choice is explicit and Create waits for it, with the reason tied to the button; with none, Organization home as before. The hint under Opens says what crew will get.
- Idle sign-out defaults to 30 minutes - The default was five minutes, which is short for a counter tablet. The 30-second floor and the 12-hour shift ceiling are unchanged, and a kiosk that never had a timeout set picks up the new default with nothing to change. The list reads it back plainly — “30 minutes (default)”, “45 seconds”, “1 hour 30 minutes”.
- A kiosk’s name and timeout are editable - Tuning the number used to mean revoking the kiosk and enrolling it again, during service, on the one device the store signs in on. The Signs out column is now a click-to-edit minutes box, and clearing it puts the kiosk back on the platform default. Validation runs before anything is written, so a good name beside an impossible timeout changes neither; a revoked kiosk is refused rather than edited, because its row is the record of which tablet a shift signed in on; and every change is audited.
Which app a tablet opens, and which store’s crew its picker shows, stay revoke-and-enroll. Both change what a bound tablet does next, which is a different act from tuning a number — and a takeover always leaves a revoke in the audit trail.
Custom Apps Can Declare Their Own Checks
An app’s own health was only knowable by opening it. A custom app can now nominate Oxy Functions as checks and have them run on demand — the building block for gating a release on the app still working.- Mark a function as a check - Set
"check": trueon a function inoxy-app.json. The flag is validated at publish and surfaced through the admin API alongside the rest of the app’s functions. - Run them from the CLI -
oxyc checks run <org>/<app>resolves the app, runs every declared check, polls each to completion and answers on the exit-code contract — so a pipeline branches on the result instead of parsing output. A check fails when it throws, times out, or returns{ ok: false }. - Silence is distinguished from success - An app declaring no checks exits differently from an app whose checks passed, and an unknown app differently again, so a misspelled app name can’t read as a green run.
The Apps Admin Is One List With Real Filters
Managing custom apps started on a gallery of cards grouped by organization, where each card spent about ten elements saying three things — name, live, last active — and an app’s health lived on a separate tab listing the same apps with different facts.- One list, one status column - The landing is a single ungrouped list sorted worst-first, with each app’s health verdict in its own column, or Draft if it was never published. The separate Health tab is gone, and organization becomes a filter and a column instead of a grouping.
- Filters that double as the summary - Search, an organization filter, and status chips that also carry the count for each state. Layout and grouping moved behind one View menu.
- Color means look here - Only Down, Degraded and Not measured get a colored pill; Quiet, Operational and Draft are plain muted text, so a scan of the list lands on the apps that actually need attention.
- Less noise on a resting row - The source badge, the workspace-id prefix, and the buttons that duplicated the row menu are gone. Each app’s icon stays — on the list row, the rail and the detail header — because it is the fastest way to tell same-named apps apart.
- One app source - Custom apps are served from published bundles only. The
v0source (proxying a hosted Vercel URL) and thelocalsource (serving a directory off the Oxy host) are removed, along with the filesystem browser and the never-expiring app API-key minter that came with them. Add custom app is now a single form: name, organization, workspace.
Health covers the first 200 apps, and the toolbar says so when there are more. Past that cap an app’s status is unknown, shown as a dash rather than Not measured — and the Needs attention filter can’t include it, because nothing has measured it yet.
The Admin Console Has One Frame, and /admin Is a Page
The staff console had grown to nineteen pages with no shared skeleton: twelve spellings of the same page wrapper, page titles that disagreed with the rail that linked to them, and fifteen hand-written “Failed to load” messages, not one of which offered a retry.- /admin answers “what needs attention” - It used to bounce straight to Custom apps. It now aggregates workspace health, internal jobs, compile revisions and custom apps into one ranked attention list, and only shows a section the viewer can actually reach.
- A failed load says what failed, and offers Retry - Every async panel in the console now reports the server’s own message with a Retry beside it. Several pages previously rendered a failed fetch as an ordinary empty state — an error looking exactly like “there is nothing here”.
- Workspace Health groups by cause - The same 206-character connector error was printed once per affected workspace, so one real cause read as nine separate incidents and overflowed the viewport. The failure is now stated once, with the workspaces it affects nested beneath it. The flat table stays available as a view.
- One status vocabulary, correct in both themes - Down, degraded, not measured, quiet and operational now look the same wherever they appear, in light mode and dark.
- The tenants overview is reachable - The page existed and nothing in the product linked to it. It is named in the console’s route map and linked from the new home.
/admin no longer redirects to Custom apps. Anyone with it bookmarked now lands on the attention list instead; Custom apps is one click away in the rail.One Console per Custom App
Custom apps admin was four sibling tabs — Apps, Organizations, Publish tokens, Storage — over a registry table that rendered three rows in a full-height viewport. Those are four object types presented as four tasks, and an operator arrives with a question about one app.- The fleet hands you to an app - Apps is the fleet list; each app now has its own console holding the nine things you need to answer for it, each panel naming the question it answers. “Is it broken” leads, rather than sitting below three reference panels.
- The console leads with the app itself - Where the app is served, and the way in to its request log — which is the only place the product shows what a custom app actually calls, and nothing about a button marked Inspect said so. Storage, publishing and CI, and the manifest are reference material alongside.
- A column appears only if some app can answer it - Requests and Last active used to render a dash on every row forever. A column that nothing can answer is dropped and named once with the reason, so “nothing here measures that” is distinguishable from “somebody removed it”. Requests keys on whether capture is configured rather than on the values, because every app sitting at zero requests is real information when capture is on and an artefact when it is off.
- A filter appears only if it selects part of the list - A fleet where every app shares one status used to offer three chips over the same three apps. A chip that selects everything is the default by another name, so there is no “All” chip either — clearing a filter is the absence of one.
- Worst-first, and search narrows rather than reorders - The list is ordered by which app most needs a person, so its top row is the one to open. Storage totals and the access and publish audits sit at the foot of the fleet page, which is the only place they were ever fleet-wide facts.
- Only one control claims ⌘K - Two controls printed the shortcut — the topbar’s Search and the apps header’s Switch app — which reads as a conflict whether or not it behaves as one. The global one keeps the badge; Switch app keeps its button and still opens the same palette.
Publishing an App Catches What Used to Fail at the First Call
An Oxy Function’s powers are gated by what its manifest declares, and its sandbox is deliberately smaller than Node. Neither was checked before the first production call: actx.storage.put without storage.write, a Buffer.from, an upsert into ClickHouse — each bundled and published happily and then failed live, as an error nobody had ever seen locally. oxyc already refused a manifest whose data placement was wrong; it now reads what the functions actually call.
- A capability the manifest doesn’t declare -
oxyc validateandoxyc publishread each declared function’s entry plus every relative file it imports, and refuse actx.<area>.<op>whose capability the function lacks —secrets.write,email.send,org.read,storage.readandstorage.writeseparately,oltpandairhouse. The finding names the file, the line, the call as written, and the exact manifest edit that fixes it. - A global the isolate doesn’t have - A value use of
Buffer,TextEncoder,TextDecoder,Blob,File,FormData,crypto.subtleorprocessis refused, because none of them exists at runtime and the failure is a bareReferenceErroron the first call. Declaring or importing the name yourself, atypeofguard, or a type annotation is not a finding. - A write with nowhere declared to write - A
ctx.warehousewrite or actx.txwith nodestinations, or naming one outside them, is refused before the upload. - What the warehouse can’t do, named before you ship it - At publish, where a credential is in hand, the declared destinations are checked against the real engines:
upsertexists only on Postgres and DuckDB,ctx.txonly on Postgres, a managed-Postgres destination is pointed atctx.oltp, and a write to a connected customer warehouse needs itscustomerWarehouseWritesreason. A database list that can’t be fetched is one warning, not a blocked publish — andvalidatesays plainly that this half is skipped offline.
A Custom App Can No Longer Take the Serve Process Down With It
Oxy Functions are your JavaScript, running in the same process that serves every other app. Two things about that were unbounded and they multiplied: an isolate could grow past the pod’s whole memory budget, and nothing capped how many ran at once — each invocation held its own OS thread and its own heap, so enough concurrent calls reached the memory ceiling, where the kernel kills the process, not the request.- A memory ceiling per invocation - Each isolate now has a 128 MiB heap limit, matching Cloudflare Workers’ published number so an app author who hits it can look up what it means. A function over the limit terminates its own isolate and fails its own call; nothing else serving on that replica notices.
- A ceiling on how many run at once, per organization as well as overall - A global limit of 64 concurrent invocations, with each organization limited to 48 inside it. The per-organization permit is taken first, so a tenant at its own ceiling queues against itself rather than holding a global slot while it waits. Both ceilings are set deliberately generously: a refused invocation and a failed one look identical to the person holding the tablet, so a limit set below real traffic would manufacture exactly the outage it exists to prevent. They will be tightened against measured usage, not against a guess.
- Busy means wait, not fail - An invocation waits up to fifteen seconds for a permit — far above a normal call — and only a wait longer than that is shed. A caller that gives up first isn’t counted as shed at all. Both new failures are attributed to the app that caused them rather than to the platform, so a shed load doesn’t page anyone or dent another tenant’s error rate.
- The bundle cache is bounded in bytes, not files - It held up to 8,192 entries with no size limit, and every publisher sizes their own assets, so the real ceiling was set by tenants. It now has a 256 MiB budget; an object larger than an eighth of that is served without being cached, rather than evicting a large share of everything else to hold something first in line to go. The entry count still applies alongside it — whichever binds first wins.
Every limit here is tunable per deployment, and the memory ceiling and both concurrency caps are sized well above observed usage — a function within its normal footprint sees no change. An app that was quietly relying on more than 128 MiB of heap, or on running more than 48 invocations at once, will now be told so instead of contributing to a restart. The memory ceiling is a containment limit rather than a rejecting one — it costs one terminated invocation, never the pod — so it stays where it is.
Metrics for the Serve Fleet
Oxy exported traces and logs and nothing else, so route latency, error rate and pool saturation were only answerable by scanning spans after the fact — expensive, retroactive, and useless to write an alert against. There was no/metrics on oxy serve at all, which left the entire serve fleet unmeasured.
- A scrape endpoint on serve and the IDE -
GET /metricsin Prometheus text format, on its own listener behindOXY_METRICS_PORTand off by default. It is deliberately not part of the product router: no auth surface, in-cluster only, and a scrape is not counted as application traffic.oxy worker’s existing endpoint gains the same block, with everything it already published unchanged. - What’s instrumented - Request rate, errors and duration per route; Postgres pool occupancy, saturation and probe timing; custom-app serve and function timings; and isolate concurrency — live, peak, and abandoned.
- Alerts that can actually fire - Three separate ways a series read as coverage while being incapable of firing are closed. The abandoned-isolate counter was published by only one fleet and sat pinned at zero; it is now emitted by every role. The new limit gauges were resolved lazily and read
0— which is also what “this limit is disabled” means — until a replica’s first invocation; they are now resolved at startup. And counters that export nothing until their first event were invisible to a rule watching for an increase, because the series is born carrying that event; they are now seeded so a first, isolated occurrence is visible. - The same push path as everything else - One instrument set serves both the scrape endpoint and the existing OTLP exporter, so a deployment already collecting traces and logs gets metrics through the same collector without a second configuration.
Rewind an Airway Pipeline Without Dropping What It Landed
Re-pulling a pipeline from an earlier start date used to mean Reset schema, which clears the cursors only after dropping the destination tables — so “rewind to an earlier position” and “delete everything this pipeline has ever landed” were the same button. On a pipeline whose resources aren’t equally re-fetchable, that isn’t a trade-off anyone can take: a source that serves only today’s snapshot, appended daily on purpose, is gone for good once the table is dropped.- Cursors reset on their own - A pipeline’s incremental position can now be cleared without touching the tables it has already loaded or the schema it recorded. Reset schema is unchanged and still the right tool when a rebuild is what you want.
- Per resource, not per pipeline - Name the resources to rewind and the rest keep their position, so re-pulling one table’s recent history doesn’t restart every other table beside it. Naming nothing clears every cursor the pipeline holds.
- It refuses when a re-pull would duplicate - Dropping the tables first made every write mode safe by construction; keeping them doesn’t — a resource that merges on a key converges on an overlapping re-pull, an append-only one duplicates. The reset reads what actually landed and declines with the reason, naming every table that would duplicate rather than the first, so a decision to override is made on all of the facts. Overriding is explicit, and every reason it overrode is logged.
- Judged on the key that is really there - A table declared as merging but carrying no key silently appends, so the check reads the effective key rather than the declared intent, and says so in those words when that is why it refused.
- Any destination, not just Airhouse - A cursor reset resolves no destination and mints no credential, so unlike Reset schema it works on every pipeline regardless of where it lands.
When a pipeline’s recorded schema holds a table that no resource claims, the check widens to the whole pipeline and names that table. Over-refusing costs an explicit override; under-refusing costs duplicate rows in an append-only table, which is the kind of error nobody notices until a total is wrong months later.
Platform Improvements
Organization Settings
- A location’s place id is editable in Settings - Custom apps key a location by the tenant’s own id — the
santa-clarain a runbook — but the Settings dialog only ever wrote the per-system external id map, so a location created there had no place id at all and apps failed to resolve it with an error pointing back at a field the dialog didn’t have. The dialog now carries a Place id field, shown under the name in the locations table and settable on both create and edit; clearing it is blank or empty. - Two locations can’t claim the same place id - Create and edit now refuse an id another location in the organization already carries, the same rule the per-system id map already enforced. Re-sending a location’s own id isn’t a collision, and a different organization may reuse the id freely.
- The Enroll worker dialog no longer overflows - The “Where they work” row grew wider than the dialog when its selects held long labels, dragging every field in the form past the edge. The row now fits, and a long name in the person picker truncates with an ellipsis instead of running over the button beside it.
Custom Apps and the SDK
- A panel whose read was cancelled no longer renders skeletons forever - Every fetching hook in
@oxy-hq/sdkdecided whether to report a rejected request by matching the error’s name, so any cancellation the hook had not itself caused was swallowed — leaving the read onloading: truewith no rows and no error, and nothing left to settle it. One app that gates a semantic read on another rendered eight stuck skeletons with nothing on screen to explain them. A hook now stays silent only when it tore the request down itself, and an agent run’s event stream treats a cancellation it didn’t cause as a dropped stream and reconnects, which is what its retry loop is for. refetch()actually refetches - The value it bumped was missing from the effect that watches it, sorefetchwas a no-op across ten metric-tree and world-model hooks — a panel whose read had failed could not be retried at all.ctx.queryandctx.cryptoare typed as the host actually behaves -ctx.query(sql)was declared as returning an array of rows, but has always resolved to{ rows, truncated }— so the destructure in the authoring guide was a type error. Andctx.crypto(hmac,verifyHmac,timingSafeEqual) was bound at runtime and documented but never declared, which made the documented webhook verification a type error for something that works. Both now match the host.- A function’s request carries its method and headers, in the types too - The host has always handed a function
{ method, headers, body }, but the type named onlybody— so the webhook example in the authoring guide, reading a signature header off the request, was a type error against the SDK that documents it. Both fields are now declared. Header names are lower-case, a repeated header keeps its first value, and a scheduled or pipeline-triggered run reportsPOSTwith no headers. No app behaves differently; one that was casting around the old type can stop. - A header the host can’t build says which header - A
ctx.fetchwith a header value the host couldn’t assemble failed with an opaque builder error that named nothing and was counted as a platform fault. It is now checked where the header is set and refused by name, so the app sees what to fix and nobody is paged for it. - Hitting your own email cap reads as your own cap - A function sending past the per-invocation email limit reported the same rate-limit error the platform writes when the mail provider throttles, so an app looping past a countable ceiling of its own paged on-call. The two are now separate errors, and only the provider’s is treated as a platform problem.
- An app’s own bad arguments no longer page on-call - A host call refused because of the app’s own arguments — an email missing its
subject, or with no recipient — was classified as a platform failure and alerted. Those are now recognized from the host’s own error label whatever phrasing follows, so an app-side mistake fails the same way every time and reaches nobody. - A real break is no longer masked by a routine one - A caught host-call failure was identified by its operation and kind alone, so a function already swallowing a routine failure on an operation hid a new, genuine break of the same shape — and any later success of that operation cleared the note, even to a different target. A caught failure now carries its message and its target, so a
ctx.fetchbreaking on one vendor still surfaces while another keeps working, and two inserts into different tables are two separate patterns. - A function that swallows a failure no longer looks healthy - A function that caught a failed host call and returned normally recorded a clean run, so nothing paged. That is precisely how the ClickHouse insert outage stayed invisible: every warehouse insert failed and the functions caught each one. A caught host-call failure now records a signal naming the operation and the kind of failure, on a run that otherwise succeeded. A caught “not found” is ordinary control flow and never pages, and neither does the host rejecting the call’s own arguments — that’s the app’s to fix and fails identically every time. A later success of the same operation clears the note, so a retried fetch that answered on its second attempt pages nobody.
Cameras
- A rolling deployment can no longer wedge camera ingest for an hour - When a pod’s warehouse connection dropped during a rollout, its reconnect could hang inside the connection handshake in a session the load balancer held open for a full hour. The background task was still running, so the connection read as live and was never replaced — every camera log write returned an error and rows were discarded two minutes at a time, until the pod was deleted by hand. Each connection attempt is now bounded end to end (30 seconds by default, overridable) and a timeout counts as an ordinary reconnect failure, so a rollout that finishes in a few minutes heals on the next retry.
Workspace Isolation
- A run belongs to one workspace - Agent, automation and pipeline routes that act on a single run — reading its event log, cancelling it, answering a suspended run, reverting its file changes — checked only that the caller belonged to the workspace named in the request, then acted on whatever run the id pointed at. A member of one workspace holding an id from another could read that run’s LLM output, tool and SQL results, or cancel a run that was still going. Every such route now confirms the run belongs to the workspace, and a run from elsewhere answers exactly like a run that doesn’t exist, so its existence can’t be probed either.
- Automation and pipeline run history no longer mixes workspaces - The run-history lists matched a pipeline or automation on its workspace-relative path alone. Because workspaces created from the same template share those paths, each one saw the other’s runs in its dropdown — run ids, statuses, timestamps and backfill windows. Both lists now filter on the workspace as well.
- Each workspace keeps its own Airway cursor and schema - Incremental pipeline state was keyed by pipeline name alone, so two workspaces running a pipeline of the same name shared one cursor and one stored schema. The second workspace resumed from the first’s position and skipped rows it had never loaded, and a Reset schema in either cleared both. State is now per workspace, and a workspace’s first run after the upgrade continues from exactly the cursor it would have used.
- A pipeline’s audit trail records which workspace ran the load - Load audit rows carry the workspace alongside the pipeline name, so history for a shared pipeline name can be read per workspace. Older rows are attributed where the owning run makes that possible.
Workspace Configuration
- A
config.ymlthat declares no models or no databases parses again - Those were the only two sections in the file with no default, so omitting either failed the whole file to parse — and the platform then carried on with an empty configuration, silently ignoring every integration, database and model the file did declare. It resurfaced further downstream as “not configured”, which reads like the workspace’s mistake rather than ours. Both sections now default to empty, which is a valid value for each. The published configuration schema moved with them, sooxyc validateand theconfig.ymleditor accept exactly the file the parser now accepts instead of flagging it as missing a required key.
Admin Console
- An organization picker no longer reports that an organization does not exist - The admin surfaces that page through every organization — scope pickers, grant forms, assign-to-organization — fell back to “No organizations found”, or to a silently short list, when one page of that listing failed: no spinner, no error. An operator acts on that answer, concluding a tenant is gone or creating a duplicate. A failed listing now shows the server’s own message with a Retry.
- A failed organization listing stops instead of retrying forever - The same listing restarted its retry cycle each time it exhausted it, issuing roughly eighteen requests in thirty seconds with no end. It now stops and reports. The fix went into the shared listing rather than one caller, so all six admin surfaces that page through organizations get it.
- Provisioning search finds rows that are not provisioned yet - Search on the transactional-store admin page silently skipped them, so an organization awaiting provisioning could not be found by name — exactly the row someone searching is usually looking for.
Warehouse Connectors
- Wide integers keep their digits, and four conversions stop lying - Four reads that a shape-comparison suite had pinned as current behaviour are now correct, and a custom app reading through
ctx.warehouse.queryfelt all four. A ClickHouseUInt64above the signed 64-bit range failed the whole read rather than the one value; 128- and 256-bit integers lost their low digits by passing through a float; a Postgres"char"column was refused with a message naming neither the column nor the type; and a DuckDB nanosecond timestamp before 1970 rounded the wrong way. Wide integers now come back as exact decimal strings, the same way DuckDB’s unsigned 64-bit integers already did.
Airway ELT
- A pipeline that can never run on this node fails instead of retrying in silence - A directly-triggered pipeline whose definition the claiming worker couldn’t resolve was handed back to the queue every 30 seconds — for thirteen hours in one case, and across a thousand assignments in two days — while its run sat in running the whole time. Nothing alerted and no error was recorded anywhere, so the only symptom was a run that never finished. A task that cannot run is now failed with the reason attached, where it is visible in run history and can be alerted on.
Local Development
- Stopping one server no longer tears down another’s database - Shutting down any
oxy serveremoved theoxy-postgresandoxy-clickhousecontainers by name, whether or not that process had started them — so a second server run against its own database took theoxy startdev stack down with it. Data survived, in named volumes, but the stack didn’t. A container is now claimed by its id at the moment it is created, and shutdown removes only what that process created.
Accessibility
- Status text meets AA contrast on the tint it sits on - The success and warning text colors were chosen against a white background, but they are read on the tinted pill or badge around them, where they measured 4.45:1 and 4.38:1 — under AA for small text everywhere the pair is used together, which is every status pill and badge in the product. Both are a step darker now, reaching 6.33:1 and 6.18:1 on their tints and improving on white too. The fills are unchanged, so a status is no less visible at a glance, and status dots, rings and borders keep their color deliberately: they are non-text and darkening them would only make a dot harder to see. Error and neutral already passed, and dark mode already passed throughout.