@oxy-hq/cli 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -3,9 +3,12 @@
3
3
  `oxyc` — a `gh api`-shaped client for the Oxy HTTP API, plus the tooling that
4
4
  manages customer workspace repos.
5
5
 
6
- - **API client** — `api`, `routes`, `schema`, `openapi`, `login`, `whoami`, `assume`
6
+ - **API client** — `api`, `routes`, `schema`, `openapi`, `login`, `whoami`, `assume`, `oltp`
7
7
  - **Customer workspaces** — `list`, `new`, `import`, `doctor`, `update`, `adopt`, `launch`
8
- - **Custom apps** — `publish`, `init-ci`, `proxy`
8
+ - **Custom apps** — `publish`, `init-ci`, `proxy`, `apps`
9
+ - **Checks** — `checks run`
10
+ - **Sandboxes** — `env`, `fn call`, `invocations`, `logs`
11
+ - **Workspace previews** — `preview`
9
12
  - **Development** — `validate`, `mcp`, `guide`, `skills`
10
13
 
11
14
  ## Install
@@ -76,7 +79,10 @@ Credentials are stored **per deployment**, and the default is production. A
76
79
  oxyc api <path> [flags]
77
80
  ```
78
81
 
79
- `<path>` is relative to `/api`; a leading `/` or `api/` is accepted.
82
+ `<path>` is relative to `/api`; a leading `/` or `api/` is accepted. One
83
+ exception: `customer-apps/…` is sent as written, because `/customer-apps/<org>/<app>/…`
84
+ is where an app's bundle is served. The app registry API is `api/customer-apps/…`
85
+ — spell the `api/` out for it (a 404 on the bare form says so).
80
86
 
81
87
  | Flag | Meaning |
82
88
  | --- | --- |
@@ -110,9 +116,10 @@ the server.
110
116
  `oxyc login`; `/external/api/**` sends `X-API-Key` from `$OXY_API_KEY`.
111
117
 
112
118
  **`--paginate` is a heuristic.** Oxy sends `Link: rel="next"` on some endpoints
113
- and nothing on others; where there is no header, `oxyc` reads
114
- `pagination.has_next`, then `has_more`, then `page < total_pages`. Nothing
115
- recognised means one page, and it warns when the server said nothing at all.
119
+ and nothing on others; where there is no header, `oxyc` reads `next_offset`
120
+ (and asks again with `?offset=`), then `pagination.has_next`, then `has_more`,
121
+ then `page < total_pages` (asking again with `?page=`). Nothing recognised
122
+ means one page, and it warns when the server said nothing at all.
116
123
 
117
124
  ```bash
118
125
  oxyc api {org}/workspaces --md
@@ -172,6 +179,38 @@ it and `end` ends it in both. A staff 403 on a tenant surface is usually no
172
179
  active session rather than a role problem; `assume status` answers "none"
173
180
  without failing.
174
181
 
182
+ ## OLTP databases
183
+
184
+ Staff can see and request an org's transactional Postgres — the store behind
185
+ `ctx.oltp` — without a shell on the server.
186
+
187
+ ```bash
188
+ oxyc oltp status [--org <slug|uuid|url>] [--json]
189
+ oxyc oltp provision --org <slug|uuid|url> --writer app:<slug> [--writer pipeline:<source> …] [--yes] [--json]
190
+ oxyc oltp provision --org <slug|uuid|url> --branch staging [--yes] [--json]
191
+ oxyc oltp reset --org <slug|uuid|url> --branch staging [--yes] [--json]
192
+ ```
193
+
194
+ `status` lists every org, with a database or without; with `--org` it shows
195
+ that org's store and writers. No credential is ever printed.
196
+
197
+ `provision` prints what it will create and asks first, because it calls a paid
198
+ provider; without a terminal it refuses unless `--yes`. It is idempotent — an
199
+ existing database or writer is reconciled, not duplicated. `app:<slug>` is
200
+ derived the way the platform derives `ctx.oltp`'s schema (`app:store-ops` →
201
+ `app:store_ops`, schema `app_store_ops`); `pipeline:<source>` names
202
+ `raw_<source>`. A 404 can be an org outside your staff grant's scope, and an
203
+ active `assume` session closes `/admin`.
204
+
205
+ `--branch staging` also cuts the org's **staging branch**: one copy of the whole
206
+ database per org, which every app's staging environment writes to. It is made
207
+ only by this flag — never by a publish — and re-running it mints whatever a
208
+ writer added since the cut needs, without resetting anyone's data. `status`
209
+ shows its age and calls it stale past 30 days; nothing resets it on a timer.
210
+ `reset --branch staging` re-copies it from production, discarding every app's
211
+ staging data in the org, so it lists those apps and asks first (`--yes`
212
+ without a terminal).
213
+
175
214
  ## Customer workspaces
176
215
 
177
216
  A customer **is** a GitHub repo carrying the `oxy-customer` topic. Tagging the
@@ -203,7 +242,7 @@ the current directory while granting access to the customer's repo.
203
242
  ## Custom apps
204
243
 
205
244
  ```bash
206
- oxyc publish [--env <e>] [--dir <path>] [--promote] [--build-only | --prebuilt] [--json]
245
+ oxyc publish [--env <e>] [--dir <path>] [--promote] [--build-only | --prebuilt] [--json] [--allow-function-lint] [--app-env <dev-handle>]
207
246
  oxyc init-ci [--app <org>/<app>] [--environment <name>] [--force]
208
247
  oxyc proxy [--port <n>] [--allow-writes] [--allow-events] [--yes]
209
248
  ```
@@ -236,11 +275,309 @@ channel unless `--promote`. `--env` defaults to **production**; name it.
236
275
  - `.env.local`, then `.env`, are read from the directory and its parents without
237
276
  overriding the environment. The server's warnings print on stderr; `--json`
238
277
  prints its result on stdout.
278
+ - **Function lint**: before the build, each declared function's source (and the
279
+ relative imports it reaches) is checked for what the host refuses at the first
280
+ call — a `ctx.*` call whose capability the manifest lacks, a global the isolate
281
+ does not have, a `ctx.warehouse` / `ctx.tx` write outside `destinations`; before
282
+ the upload, with the target's database list, an `upsert` or `ctx.tx` on an
283
+ engine that refuses it and a customer-warehouse write with no
284
+ `customerWarehouseWrites` reason. The rules are `validate`'s, below. A finding
285
+ fails the publish with exit `1` and names the file, line, call and fix; if the
286
+ target will not list the databases, the engine half prints one warning and is
287
+ skipped. **`--allow-function-lint`** is the way past a false positive: every
288
+ finding becomes a warning that names its rule — please open an issue with it.
239
289
 
240
290
  **`init-ci`** writes `.github/workflows/oxy-publish.yml` at the repo root: a
241
291
  `build` job (no id-token) that runs `publish --build-only`, and an
242
292
  environment-gated `publish` job whose only work is `publish --prebuilt` with
243
- OIDC. It prints the `oxyc api …/publishers` call that registers the workflow.
293
+ OIDC. `--promote` makes that publish go live and adds a `checks run` step after
294
+ it, when the manifest declares a check. It prints the `oxyc api …/publishers`
295
+ call that registers the workflow.
296
+
297
+ **`--app-env <dev-handle>`** moves one sandbox's build pointer instead of the
298
+ draft/live channel — see [Sandboxes](#sandboxes). It needs a staff credential
299
+ (never a publish token or OIDC) and is refused together with `--promote`: a
300
+ sandbox build is never promoted, so the loop is publish to the sandbox,
301
+ iterate, then publish the same tree to staging and promote that.
302
+
303
+ ## Apps
304
+
305
+ Read-only: every request these make is a GET, against `/api/customer-apps/**`
306
+ (the app-admin role). `<app>` is `<org-slug>/<app-slug>` or an app UUID.
307
+
308
+ ```bash
309
+ oxyc apps list [--org <slug>] [--published | --draft] [--builds] [--json]
310
+ oxyc apps show <app> [--json]
311
+ oxyc apps builds <app> [--json]
312
+ oxyc apps health [<app>] [--needs-attention] [--json]
313
+ oxyc apps usage <app> [--json]
314
+ oxyc apps drift [<app>] [--org <slug>] [--dir <path>] [--refresh] [--json]
315
+ ```
316
+
317
+ - **`list`** — every app you can see, across organizations, walking the paged
318
+ registry to its end. `STATE` is `live` when the app has a published build and
319
+ `draft` when it has none. `SOURCE` is the repository the app is *registered*
320
+ against. `--builds` adds the live build's id, the repository and commit it was
321
+ built from, who published it and when — one extra request per app, six at a
322
+ time.
323
+ - **`show`** — one app on one screen: the registry row, the live build, the
324
+ draft build when it is newer than the live one, deployment integrity,
325
+ availability, and the 7-day usage summary. `last promote` is when the promote
326
+ or rollback action was last used; `oxyc publish --promote` does not update it,
327
+ so it can be older than the live build.
328
+ - **`builds`** — the build history, newest first. `CHANNEL` marks the build each
329
+ channel points at: `live`, `draft`, or both for one build.
330
+ - **`health`** — with no argument, the fleet table (published apps only):
331
+ `down`, `degraded`, `not_measured`, `quiet` or `operational` per app, with the
332
+ totals on stderr. `--needs-attention` keeps the first three. With `<app>`: the
333
+ deployment-integrity checks, the availability windows, and the browser errors
334
+ of the last 24 hours.
335
+ - **`usage`** — the 7-day summary, the visitors, and the tracked events by name.
336
+ - **`drift`** — see below.
337
+
338
+ Times are UTC. `--json` prints one document on stdout; a table goes to stdout and
339
+ the count line to stderr.
340
+
341
+ **An empty result and a failed request are different answers.** No apps, no
342
+ builds, no errors recorded: the command says so and exits `0`. A request that
343
+ failed exits with the code its status maps to (`4`, `5`, `7`, …). Where a
344
+ command makes several independent requests (`show`, `health <app>`,
345
+ `list --builds`, `drift`), it prints what it did read, marks what it did not as
346
+ `NOT READ` (`"failed": […]` / `live_build_error` in `--json`), and then exits
347
+ non-zero. These commands report state: a failing health check, a `down` app or
348
+ a drifted app is an answer and exits `0` — read it from the output.
349
+
350
+ The health route answers **503 with its report** when a check fails; `oxyc`
351
+ prints that as a failing report, not as an outage.
352
+
353
+ ### `apps drift`
354
+
355
+ Compares what is live against source on **this machine**. For each app it takes
356
+ the live build's recorded repository and commit, finds a local checkout of that
357
+ repository (the ones `oxyc repos` and `oxyc path` report, or `--dir`), finds the
358
+ app's directory by the `oxy-app.json` whose `slug` and `orgSlug` match — at
359
+ `<app>/oxy-app.json` or `<app>/public/oxy-app.json`, never by folder name — and
360
+ counts the commits on the checkout's **current branch** that touch that
361
+ directory after the published commit.
362
+
363
+ | Result | Meaning |
364
+ | --- | --- |
365
+ | `in sync` | no commit on the current branch touches the app directory after the published commit |
366
+ | `N commits ahead` | that many do; they are listed |
367
+ | `unknown — <reason>` | the comparison could not be made |
368
+
369
+ `unknown` is never reported as `in sync`. Its reasons (`reason` in `--json`):
370
+
371
+ | `reason` | |
372
+ | --- | --- |
373
+ | `not_published` | nothing is live |
374
+ | `source_unrecorded` | the live build records no repository or no commit |
375
+ | `repo_not_checked_out` | the repository is not on this machine — clone it, or pass `--dir` |
376
+ | `commit_not_in_checkout` | the checkout does not have the published commit — `git fetch` there |
377
+ | `app_dir_not_found` | no tracked `oxy-app.json` declares this `slug` and `orgSlug` |
378
+ | `app_dir_ambiguous` | more than one does |
379
+ | `commit_not_on_branch` | the published commit is not an ancestor of the checked-out branch |
380
+ | `working_tree_dirty` | the app directory has uncommitted or untracked files |
381
+ | `git_failed` | `git log` or `git status` failed in the checkout, so the comparison could not be read |
382
+ | `request_failed` | the builds request failed (the command then exits non-zero) |
383
+
384
+ It runs read-only git commands and nothing else: it does not fetch, check out,
385
+ or refresh the index. The answer is therefore about the checkout as it is on
386
+ disk — a branch that is behind its remote reports fewer commits than the remote
387
+ has. With no `<app>`, every published app is compared, and `--dir` applies to
388
+ the apps whose source repository is that checkout's `origin`; with `<app>`,
389
+ `--dir` is used whatever its remote is.
390
+
391
+ An app published to two organizations from one directory (a staging org and a
392
+ production org) matches the manifest for only one of them; the other reports
393
+ `app_dir_not_found` and names the manifest it found with the other `orgSlug`.
394
+
395
+ ## Checks
396
+
397
+ ```bash
398
+ oxyc checks run <org-slug>/<app-slug> # or an app UUID
399
+ oxyc checks run acme/dashboards --json --timeout 60
400
+ ```
401
+
402
+ Runs every function the app declared `"check": true` (in `oxy-app.json`), one
403
+ POST per check to start its run, then polls each run to a terminal status —
404
+ `done`, `failed`, `cancelled`, or a client-side `timed_out` past `--timeout`
405
+ seconds (default 300, per check). A check **passes** when its run finishes
406
+ `done` and its answer does not parse to an object with `ok: false`.
407
+
408
+ Human mode prints one line per check to stderr as it lands (`✓ canary 4.1s` /
409
+ `✗ canary failed: <reason>`), suppressed by `--quiet`. `--json` prints one
410
+ object to stdout instead:
411
+
412
+ ```json
413
+ { "app": "acme/dashboards", "appId": "…", "checks": [{ "name": "canary", "runId": "…", "status": "done", "passed": true, "durationMs": 4123 }] }
414
+ ```
415
+
416
+ **Auth** follows the same rule as `oxyc api`'s bearer surface, with one
417
+ difference: `checks run` talks to `/api/admin/**`, which is not the
418
+ `/external/api/*` surface, so a configured API key is sent explicitly as
419
+ `X-API-Key` only when **no bearer resolves** — never both. A bearer from
420
+ `oxyc login` (or `--token-env`) always wins when one is present.
421
+
422
+ **In CI, nothing is stored.** With neither a token nor an API key set, a job
423
+ holding `id-token: write` exchanges its GitHub OIDC token for the same
424
+ short-lived, app-scoped publish credential `oxyc publish` uses, and drives
425
+ `/api/customer-apps/**` instead — the same three handlers, mounted where a
426
+ publish token may reach them. The exchange returns the app id, so no lookup is
427
+ needed, and `<app>` must be `<org-slug>/<app-slug>` in this mode (a UUID names
428
+ an app the exchange cannot verify a publisher for). A publish token set in
429
+ `OXY_TOKEN` takes the same surface without minting.
430
+
431
+ What that credential may do is deliberately small, and enforced server-side
432
+ rather than by the client. An app-scoped token is **confined to its app**: it
433
+ reaches `/customer-apps/{its own id}/…` and the upload, and answers `404` for
434
+ everything else on that surface — another app's id, the registry listing, and
435
+ the fleet-wide rollups. That is the same answer an out-of-scope App Operator
436
+ gets, so it cannot learn which apps exist. Within its app it may publish, read,
437
+ and run a function **the manifest marks `"check": true`**; a function without
438
+ the flag is `403`. The
439
+ argument for allowing the run at all is narrow: a token that may publish
440
+ arbitrary code to an app can already cause anything that app can, so running
441
+ that app's own declared checks adds nothing. It would stop being true the moment
442
+ it admitted any function.
443
+
444
+ **A check runs against the app's live build** — the server resolves
445
+ `published_build_id`, else `draft_build_id`, for the pre-flight lookup and for
446
+ the execution alike. So `checks run` after a plain `oxyc publish` verifies the
447
+ *previously promoted* code, not the draft just uploaded. `oxyc init-ci` writes
448
+ the step only with `--promote`, where the build just published is the one the
449
+ check runs, and only when the manifest declares a check. **With `--app-env`**
450
+ it runs against that environment's own build instead — a sandbox's own
451
+ publish, or staging's — and the report gains `environment` and each check's
452
+ `invocationId`; the publish-token and OIDC paths are unchanged and work only
453
+ against production, since every non-production operation needs a staff
454
+ credential (see [Sandboxes](#sandboxes)).
455
+
456
+ With a publish token, `<app>` must be the **UUID** — resolving a slug means
457
+ listing every app, which such a token may not do. The OIDC path needs no id at
458
+ all (the exchange returns it) but does need `<org-slug>/<app-slug>`, since the
459
+ exchange is registered by slug.
460
+
461
+ **Exit codes:** `0` every check passed; `9` (`CHECK_FAILED`) at least one check
462
+ failed or timed out; `1` the app declares no checks; `5` the app was not
463
+ found; `4` no credential resolved, or the API rejected it (401/403 — an
464
+ expired 90-day API key reads this way, not as a missing one); `2` a bad
465
+ `--timeout`, or a slug where the credential needs a UUID; `7` (`UNAVAILABLE`)
466
+ the deployment answered the OIDC exchange without an `app_id`, meaning it
467
+ predates trusted checks.
468
+
469
+ ## Sandboxes
470
+
471
+ ```bash
472
+ oxyc env create <app> <name> # name is dev-<handle>, 1-12 chars a-z0-9-
473
+ oxyc env list <app>
474
+ oxyc env show <app> <name>
475
+ oxyc env delete <app> <name> [--yes] [--wait [seconds]]
476
+
477
+ oxyc publish --app-env <name> [--env <e>] # see "Custom apps" above
478
+ oxyc fn call <app> <function> [--app-env <name>] [--data <json|@file|->]
479
+ oxyc checks run <app> --app-env <name> # see "Checks" above
480
+
481
+ oxyc invocations list <app> [--app-env] [--build] [--function] [--limit]
482
+ oxyc invocations held <app> <invocation-id>
483
+ oxyc logs <app> [--app-env] [--invocation] [--request] [--hours] [--limit]
484
+ ```
485
+
486
+ A **sandbox** is a named, short-lived `dev-<handle>` environment of one custom
487
+ app: its own build pointer, storage silo, secrets, Airhouse sibling and — in
488
+ an org with an OLTP staging branch — its own copy of the app's database, with
489
+ reads hitting production and most writes either isolated or *held* (recorded,
490
+ not performed — see `invocations held`). It starts with no build, is deleted
491
+ explicitly or after 7 idle days, and an app has at most 20. Full contract,
492
+ including what is **not** isolated (`ctx.warehouse`) and the other gaps:
493
+ `internal-docs/custom-app-sandboxes.md`.
494
+
495
+ `ctx.oltp` in a sandbox runs in a schema of its own (`app_<app>__dev_<handle>`)
496
+ inside the org's staging branch. `publish --app-env` queues it: a copy of
497
+ staging's tables (rows up to a size cap), then the build's OLTP migrations.
498
+ `env show` reports it as `oltp_schema` — `status` is `seeding`, `ready`,
499
+ `failed` (with `error`) or `stale` (the branch was reset; publish again), and
500
+ `structure_only` lists the tables copied empty. Until it is `ready` a
501
+ `ctx.oltp` call in the sandbox is refused with a message that says which.
502
+
503
+ The loop: `env create` → `publish --app-env` → `fn call` / `checks run
504
+ --app-env` → `invocations list` / `held` to read back what ran → iterate from
505
+ `publish` → `env delete --yes --wait` when done. `oxyc guide` has the same six
506
+ lines, meant to sit in an agent's context.
507
+
508
+ **`--app-env <environment>`** (`production`, `staging` or `dev-<handle>`,
509
+ validated client-side — exit `2` on a malformed name) is a *different axis*
510
+ from `--env`, which is the deployment: both can appear on the same command
511
+ (`oxyc fn call acme/store f --env dev --app-env dev-a1`). It is not a global
512
+ flag — only `publish`, `fn call`, `checks run`, `invocations list` and `logs`
513
+ take it. A publish-token credential is refused before any request for
514
+ `--app-env` other than `production` (exit `2`); `env`, `invocations` and
515
+ `logs` refuse one outright, for any environment, because sandbox management
516
+ is a staff console surface.
517
+
518
+ **`env delete`** without `--yes` asks on a terminal and exits `8` (refused)
519
+ off one, same as `oltp reset`. `--wait [seconds]` (default 120) polls until
520
+ the teardown finishes and prints `{"name","status":"deleted"}`; past the
521
+ deadline it exits `7` (retryable) rather than hanging.
522
+
523
+ **`fn call`** POSTs directly to `{target}/customer-apps/{org}/{app}/fn/{name}`
524
+ (no `/api`) — the response is Server-Sent Events, not JSON, so `oxyc` parses
525
+ the `log` / `data` / `done` / `error` frames itself. It exits `0` only when
526
+ the stream ends `done` with a 2xx status; a function that threw, or answered a
527
+ non-2xx, is a function-level failure (exit `1`, `ok: false` in the JSON), kept
528
+ distinct from a non-2xx on the POST itself (exit per the usual status mapping
529
+ — e.g. `EnvironmentHasNoBuild` before the stream starts). The
530
+ `x-oxy-invocation-id` response header becomes `invocationId` in the result.
531
+
532
+ **`invocations held`** is what the non-production write policy did *not* do —
533
+ production's would-be effect, recorded rather than performed. It is `[]` for a
534
+ production invocation, since nothing is held there.
535
+
536
+ ## Workspace previews
537
+
538
+ ```bash
539
+ oxyc preview create <branch> [--wait [seconds]] [--json]
540
+ oxyc preview list [--json]
541
+ oxyc preview show <branch> [--json]
542
+ oxyc preview delete <branch> [--yes] [--json]
543
+ oxyc preview checks <branch> [--json]
544
+
545
+ oxyc preview run <branch> <kind> <ref> [--variables <json|@file|->] [--read-live-only]
546
+ [--window-from <iso>] [--window-to <iso>] [--resource <name>...]
547
+ [--wait [seconds]] [--json]
548
+ oxyc preview runs list <branch> [--json]
549
+ oxyc preview runs show <run-id> [--wait [seconds]] [--json]
550
+ ```
551
+
552
+ A **preview** opens a workspace branch on the real product, against real data,
553
+ without the branch being live — staff only. `create` compiles the branch's
554
+ head into a staging revision (or reuses a ready one for that commit) and is
555
+ idempotent; `--wait [seconds]` (default 120) blocks until the compile is
556
+ `ready` or `failed` instead of returning the `compiling` item the request
557
+ itself answers with, and exits `7` (retryable) past the deadline. There is no
558
+ single-preview route: `show` fetches `list` and filters it client-side, so its
559
+ `5` (not found) is synthesized here, not a literal 404.
560
+
561
+ The loop: `preview create --wait` → `preview checks` → `preview run` →
562
+ `preview runs show --wait` → iterate → `preview delete --yes` when done.
563
+
564
+ **`<kind>`** is `procedure` or `airway_sample` — the only two `POST
565
+ /previews/runs` accepts (exit `2` client-side on anything else, naming the
566
+ read-back verb). `transform_build` and `compare` runs are queued by the
567
+ server's own Airway change check, never started here; `preview runs list`
568
+ surfaces them alongside the kinds you *can* start, and `preview runs show
569
+ <run-id> --wait` reads any of the four back — a procedure's held steps, a
570
+ transform build's or compare's outcome, or a sample's ask and result.
571
+ `--variables` / `--read-live-only` are `procedure`-only; `--window-from` /
572
+ `--window-to` / `--resource` (repeatable) are `airway_sample`-only.
573
+
574
+ Every verb needs `{workspace}` resolved — `--workspace <id>`, exactly the way
575
+ `oxyc api {workspace}/...` resolves it; there is no second workspace
576
+ resolver. A deployment with `OXY_PREVIEW_RUNS` off answers every runs route
577
+ `404 preview_runs_disabled`, surfaced the same way any other server refusal
578
+ is: the server's own `code` rides along on the thrown error, and the exit
579
+ class follows the usual HTTP-status mapping (`400`/`409` → `6`, `404` → `5`).
580
+ Full contract: `internal-docs/workspace-previews.md`.
244
581
 
245
582
  ## Development commands
246
583
 
@@ -267,6 +604,51 @@ generated from the Rust config types. No network, no token.
267
604
  Structural checks only. `oxy validate` additionally resolves `databases:` and
268
605
  `llm.ref` against the loaded workspace, and wins where the two disagree.
269
606
 
607
+ Every **`oxy-app.json`** it finds is checked for data placement — the shape of
608
+ the data picks the store: facts and history in Airhouse (`ctx.airhouse`),
609
+ records in OLTP (`ctx.oltp`), files in `ctx.storage`, customer warehouses
610
+ read-only. These fail the run:
611
+
612
+ - `customerWarehouseWrites` that is not `{ "<database>": "<reason>" }`, or names
613
+ a database missing from the function's `destinations`;
614
+ - `"airhouse": { "enabled": true }` in an app whose slug cannot derive a writer
615
+ (`-` → `_`, 1–56 of `[a-z0-9_]`, starting with a letter; no `_` in the slug);
616
+ - `airhouseMigrations` without a `dir`, with one that does not exist, or with
617
+ `migrations.dir`'s; and in its `*.sql`, `PRIMARY KEY`, `UNIQUE`,
618
+ `CREATE INDEX`, `REFERENCES` or `FOREIGN KEY` (DuckLake has none), or a
619
+ `CREATE`/`ALTER`/`DROP`/`INSERT`/`UPDATE`/`DELETE`/`COMMENT ON` target not
620
+ written as `app_<writer>.<name>`.
621
+
622
+ Warnings print on stderr and do not fail: each customer-warehouse exception,
623
+ `CREATE SCHEMA` in a migration, and `ctx.secrets.set` holding state (a
624
+ `JSON.stringify` value, or a state-like key that is not a credential's). The SQL
625
+ checks are lexical; the server's parser is the authority.
626
+
627
+ Each declared **Oxy Function** is then linted — its entry and every relative
628
+ import it reaches, comments and strings masked — for the mistakes that work
629
+ locally and fail closed in production. These fail the run, each naming the
630
+ file, the line, the call and the fix:
631
+
632
+ - `capability` — a `ctx.<area>.<op>` call whose manifest capability the function
633
+ does not declare: `ctx.secrets.set` → `secrets.write`, `ctx.email.send` →
634
+ `email.send`, `ctx.org.*` → `org.read`, `ctx.storage.*` → `storage.read` /
635
+ `storage.write` (`copy` needs both), `ctx.oltp.*` → `oltp.enabled`,
636
+ `ctx.airhouse.*` → `airhouse.enabled`. The map is
637
+ `src/publish/capabilities.ts`, held to the host's `FunctionCapabilities` by a
638
+ Rust test.
639
+ - `isolate-global` — a value use of `Buffer`, `TextEncoder`, `TextDecoder`,
640
+ `Blob`, `File`, `FormData`, `crypto.subtle` or `process.*`: the isolate is bare
641
+ `deno_core` and each is a `ReferenceError` at runtime. A name the file declares
642
+ itself (`const Buffer = …`, `import { TextEncoder } from "./polyfill"`) is not
643
+ one; nor is a `typeof` guard or a type annotation.
644
+ - `destinations` — a `ctx.warehouse.insert` / `exec` / `upsert` or `ctx.tx` in a
645
+ function with no `destinations`, or naming a database not in them.
646
+
647
+ The engine half — `upsert` off Postgres / DuckDB, `ctx.tx` off Postgres, a
648
+ customer-warehouse write with no `customerWarehouseWrites` reason, a write to
649
+ the org's OLTP store — needs the database list, which only the server has;
650
+ `validate` says so and `oxyc publish` runs it before the upload.
651
+
270
652
  **`proxy`** forwards a local dev server's Oxy calls to a cloud target with your
271
653
  login token attached. Defaults: side-effecting calls are **held**, tracking
272
654
  events are **dropped**, auth endpoints reach the backend unauthenticated so
@@ -275,9 +657,22 @@ production target (any host under `oxygen-hq.com`) is refused without `--yes`.
275
657
  Each Oxy Function call carries a W3C trace — the SDK's, or one minted here — and
276
658
  prints `↳ <status> fn <name> request_id=… trace_id=…`.
277
659
 
278
- **`mcp`** serves the API over stdio as four tools — `oxy_routes`, `oxy_schema`,
279
- `oxy_request`, `oxy_whoami` — rather than one per endpoint, so the tool schemas
280
- cost ~2 KB per turn and reach endpoints added after this package shipped.
660
+ **`mcp`** serves the API over stdio as four generic tools — `oxy_routes`,
661
+ `oxy_schema`, `oxy_request`, `oxy_whoami` — rather than one per endpoint, so
662
+ those schemas cost ~2 KB per turn and reach endpoints added after this
663
+ package shipped. Two named exceptions trade that minimalism for validation an
664
+ agent needs to drive a loop unsupervised: the **sandbox loop**
665
+ (`oxy_env_create`/`list`/`show`/`delete`, `oxy_publish_sandbox`,
666
+ `oxy_fn_call`, `oxy_checks_run`, `oxy_invocations_list`/`held`, `oxy_logs`)
667
+ and **workspace previews** (`oxy_preview_create`/`list`/`show`/`delete`/
668
+ `checks`/`run`/`runs_list`/`run_show`). Each tool calls its CLI verb's own
669
+ request-building function, so a tool's result is the same JSON document that
670
+ verb's `--json` prints, and a failure carries the server's own error code
671
+ plus the exit-code class (`[exit 5 NOT_FOUND]`) the way `oxyc`'s exit code
672
+ would. `oxy_env_delete` / `oxy_preview_delete` need `confirm: true` (there is
673
+ no terminal to ask on inside an MCP server), and `oxy_publish_sandbox`
674
+ requires a `dev-<handle>` `appEnv` and refuses production, staging or a
675
+ promote client-side — it can never reach the live channel.
281
676
 
282
677
  ```bash
283
678
  claude mcp add oxyc -- npx -y @oxy-hq/cli mcp --env production
@@ -299,6 +694,7 @@ once npm reclaims it.
299
694
  6 the request was malformed (4xx other than 401/403/404)
300
695
  7 unavailable — 5xx, a timeout, or the network failed. Retryable.
301
696
  8 refused — the operation would have destroyed or overwritten something
697
+ 9 a check ran and failed or timed out (`oxyc checks run`)
302
698
  ```
303
699
 
304
700
  `4` almost always means the wrong `--env`. `7` is worth retrying; `6` never is.