> ## 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.

# Team-Based App Access, Annotated Congestion Clips, Opt-In Workspace Health Checks, and Sharper Anomaly Attribution

> Grant custom-app access by team, annotated evidence clips, and opt-in workspace health checks.

<Icon icon="calendar" iconType="regular" size={16} /> **August 3, 2026** · v0.5.119 – v0.5.120

### New Features

#### Team-Based App Access

Restricting a custom app to part of an org is now something you can actually set up — through org teams rather than a per-app list of names. Grant an app to a team once, and a new hire joins that one team instead of being added to six apps individually:

* **Org teams** - A new **Org Settings → Teams** page creates, renames, and deletes named teams and manages who is in each one. Membership changes save immediately.
* **App access management** - **Org Settings → App access** lists the org's apps with a visibility badge and a Manage action, where an app is either visible to the whole org (the default) or restricted to specific teams and members.
* **Access-aware launcher** - The HQ home page now only shows cards for apps the viewer can actually open. A restricted app no longer appears for everyone in the org and then 403s on click.
* **Grants narrow, never widen** - A grant scopes access *within* an org; it is never a way in from outside one. Someone granted an app but not a member of the org is denied, closing a case where the app shell loaded and every query behind it failed.
* **App admin through teams** - The per-app admin role works through team grants too, so extending an app's privileged surface to the right group needs no per-person setup.
* **Three consoles, one dialog** - Org admins, Oxy staff (admin app dossier → **Access**), and `manage_apps` partners (partner console → **Access**) all get the same management surface for the apps they're responsible for. Every partner action lands in that org's append-only audit log.

Naming an app's audience is a lifecycle decision, so partners who publish an app can also decide who sees it — while an app's own admins deliberately cannot appoint their own audience.

#### Sharper Anomaly Root-Cause Attribution

The anomaly **Explain** panel used to list every declared driver under one "Driver attribution" heading with its raw period-over-period delta — which meant a driver that *offset* an anomaly looked identical to one that caused it. Drivers are now sorted by what they actually did:

* **Four honest groups** - Drivers explaining the move, drivers offsetting it (labelled as dampening it, not causing it), drivers that simply moved along mechanically, and drivers whose direction is undetermined.
* **No mechanical driver is credited as a force** - Discount dollars fall whenever volume falls; that isn't something pushing back, there was just less to discount. Such a driver is filed as mechanical and rendered after the two groups that answer the question.
* **Passthrough split** - Where a driver tracks a base, the row shows the ratio plus both halves — the part the base forced and the part the rate actually contributed. On a real net-revenue anomaly that separates a −62.68 volume effect from the one genuine +2.11 pricing decision the period made.
* **Graph tinted by real push** - Driver nodes on the explain graph are toned by how they pushed the target rather than by the sign of their own delta, mechanical drivers render neutral and label "tracks \<base>", and qualitative drivers size by their own movement instead of collapsing to nothing.
* **Magnitudes that don't overstate** - A driver with no coefficient now says "qualitative" instead of looking like it carries a magnitude, a relationship description is labelled as such, and small values use three significant figures so a meaningful rate move no longer renders as `Δ +0.00`.
* **The AI narrative follows suit** - Root-cause prose is instructed not to cite a mechanical driver as either a cause or an offset, and to quote the rate-driven part instead, so the panel and the explanation agree.

Explanations cached before this release show their drivers as unclassified with a pointer to **Refresh** rather than hiding them.

#### Annotated Congestion Evidence Clips

Congestion evidence clips now show the computer vision that fired the flag, baked into the archived video itself rather than handed over as a raw camera dump:

* **Zone, boxes, and count in the clip** - Each archived congestion clip carries the service-area zone outline, a tracking box per person (green in-zone, gray outside), and a live "N in zone" badge.
* **Only counts who's in the zone** - Boxes follow queued customers with persistent IDs through the clip and exclude people outside the polygon, so staff behind the counter don't inflate the picture.
* **Plays anywhere** - Clips are re-encoded to browser-playable H.264, so evidence opens directly in the app.
* **Evidence is never lost** - Annotation is best-effort: if it fails for any reason, the original clip is archived unchanged.

#### Workspace Health Checks Are Now Opt-In

A workspace with no `health_check:` block in its `config.yml` used to get an hourly evaluation pass — and the connection probe with it — spending a recurring warehouse round-trip (and on Snowflake a 60-second minimum warehouse resume) on behalf of workspaces that never asked for it. An absent block now means health checks are off:

* **Writing the block is the opt-in** - `health_check: { interval: 30m }` is enough to turn checks on at that cadence; there's no separate flag to remember. `enabled: false` inside a block is still the way back out.
* **An unreadable block counts as no opt-in** - A typo'd or unparseable `health_check` block now leaves checks off instead of quietly falling back to the hourly default.
* **A thinner admin health table** - The admin **Workspace Health** view lists participating workspaces only; a workspace that isn't evaluating is absent rather than shown as healthy.

This applies to existing workspaces, not just new ones — a workspace that relied on the old implicit default needs a `health_check:` block to keep being evaluated. Running a check by hand from the admin view is unaffected.

Example configuration:

```yaml theme={null}
health_check:
  interval: 30m
```

***

### Platform Improvements

#### Custom App Functions

* **Email attachments actually send now** - `ctx.email.send` documented attachment content as base64, but the Functions runtime had no way to produce it — `btoa`, `atob`, `TextEncoder`, and `Buffer` were all unavailable, so a function that generated a report and attached it failed outright. Attachments now work end to end: the SDK adds `bytesToBase64` / `base64ToBytes`, `btoa` / `atob` are available inside a function, and an attachment can declare `encoding: "utf8"` so generated text (CSV, JSON, HTML) needs no encoder at all and stays byte-exact.
* **Binary downloads are no longer corrupted** - `ctx.fetch` decoded every response as text, so fetching a PDF or PNG to attach or store returned a file of replacement characters — silently corrupt. `ctx.fetch(url, { encoding: "base64" })` now returns the bytes intact; UTF-8 remains the default.
* **Consistent encoding across the SDK** - `encoding` now behaves identically on `ctx.fetch`, `ctx.storage`, and email attachments, and encoding errors name the right helper instead of a stale message. Encoding and decoding large assets is also dramatically faster — a 10 MiB payload encodes in roughly a third of a second.
* **Binary attachments arrive intact** - A photo, PDF, or other binary file attached by a function was delivered as an unopenable file, because the outgoing message declared its attachments as plain ASCII and the bytes were stripped in transit. Oxygen now composes the message itself and always encodes attachments as base64, so a generated report or a submitted image opens exactly as sent. Text attachments were never affected, which is why the problem showed up only on binary files.
* **An empty attachment is rejected, not delivered** - An attachment with no content used to ship as a well-formed but 0-byte file that the recipient saw as corrupt, with nothing reported. It is now rejected up front with a clear error.
* **Local previews show the real message** - With `OXY_APP_EMAIL_LOCAL_TEST=1`, Oxygen now writes the exact message bytes as an `.eml` file alongside the HTML preview, so an attachment can be opened in a mail client while developing locally instead of previewing only the template.

#### Cameras

* **Clip playback works from custom apps** - The clip-playback URL is now available on the external API surface, so a custom app's evidence and compliance panels play their clips instead of showing "clip unavailable." The clips themselves were always there; only the route was missing. Minting a playback URL still verifies the clip belongs to the requesting workspace.

#### Reconciliation

* **Every verdict shows the window it compared** - The admin **Reconciliation** view now displays the exact period each check compared (start, end, and timezone) beside the two values, including on degraded checks. Previously a check comparing days the warehouse hadn't finished loading was indistinguishable from real drift.
* **Sizing guidance for `freshness`** - On a `week` or `month` grain, a `freshness` smaller than one grain doesn't buy a flat safety margin — it buys a settle time that swings with the weekday, so a check can reconcile cleanly for three days and then report drift that isn't there. Set `freshness` to at least one full grain (for example `7d` on a weekly check) for a stable margin.

#### Settings & Permissions

* **Settings shows only the sections you can use** - A Member opening Settings used to meet a blank **General** panel, a "You don't have permission" toast on **Airhouse** and the member rosters, and a denial message on **API Keys** — because most sections were offered to everyone and then refused on arrival. Each section now appears only when the current role can actually use it, and the ones a Member is entitled to — both member rosters, Airhouse, Activity Logs, and Appearance — load cleanly. Where access genuinely isn't available, a consistent notice explains it instead of an empty panel or a stray error toast.
* **Workspace admins reach their workspace settings** - Sections are now gated on the permission each one actually requires rather than on org role alone, so someone who is an org Member but a workspace Admin can reach Databases, Secrets, Apps, and API Keys — access the server already granted them but the sidebar hid.
* **Airhouse stays available to members** - The Airhouse panel remains visible to every org member, since it is their only route to a connection string and the credential it mints is their own read-only, time-limited one. Provisioning and catalog indexes stay restricted to admins.
