@oxy-hq/cli 0.1.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.
Files changed (39) hide show
  1. package/README.md +163 -0
  2. package/dist/main.mjs +6688 -0
  3. package/dist/main.mjs.map +1 -0
  4. package/dist/mcp-VdontkUr.mjs +316 -0
  5. package/dist/mcp-VdontkUr.mjs.map +1 -0
  6. package/json-schemas/agent-test.json +104 -0
  7. package/json-schemas/agentic.json +406 -0
  8. package/json-schemas/app.json +1884 -0
  9. package/json-schemas/config.json +2215 -0
  10. package/json-schemas/flow-test.json +124 -0
  11. package/json-schemas/global-semantics.json +782 -0
  12. package/json-schemas/workflow.json +1907 -0
  13. package/package.json +65 -0
  14. package/skills/oxy-airway-pipelines/SKILL.md +338 -0
  15. package/skills/oxy-automations/SKILL.md +67 -0
  16. package/skills/oxy-cli/SKILL.md +134 -0
  17. package/skills/oxy-custom-apps/SKILL.md +330 -0
  18. package/skills/oxy-data-architecture/SKILL.md +225 -0
  19. package/skills/oxy-semantic-modeling/SKILL.md +216 -0
  20. package/template/.gitattributes +2 -0
  21. package/template/.github/scripts/ci-publish.sh +77 -0
  22. package/template/.github/scripts/discover-apps.sh +175 -0
  23. package/template/.github/scripts/publish-env.sh +55 -0
  24. package/template/.github/workflows/publish.yaml +653 -0
  25. package/template/.github/workflows/validate.yaml +264 -0
  26. package/template/.nvmrc +1 -0
  27. package/template/.oxyc-managed +297 -0
  28. package/template/README.md +414 -0
  29. package/template/_gitignore +32 -0
  30. package/template/apps/.gitkeep +0 -0
  31. package/template/config.yml +53 -0
  32. package/template/memory/.gitkeep +0 -0
  33. package/template/package.json +17 -0
  34. package/template/pipelines/.gitkeep +0 -0
  35. package/template/pnpm-workspace.yaml +32 -0
  36. package/template/scripts/dev.sh +443 -0
  37. package/template/semantics/topics/.gitkeep +0 -0
  38. package/template/semantics/views/.gitkeep +0 -0
  39. package/template/workflows/.gitkeep +0 -0
@@ -0,0 +1,330 @@
1
+ ---
2
+ name: oxy-custom-apps
3
+ description: Use when building, debugging, reviewing, or shipping an Oxy custom-app bundle (a code-first React/Vite app served by oxy), including writing Oxy Functions and driving them from the UI. Encodes the contract rules, pitfalls, performance guardrails, and the self-serve `oxy publish` ship flow.
4
+ ---
5
+
6
+ # Customer-app development
7
+
8
+ Customer-app bundles are arbitrary JS apps served by oxy at
9
+ `https://<env>/customer-apps/<org>/<slug>/`. Oxy is a data gateway:
10
+ serves static assets, gates cookie auth, and proxies queries. The
11
+ bundle is opaque above the data plane — no per-app server config.
12
+
13
+ ## The Oxy App Kit (use it)
14
+
15
+ Two small packages remove the footguns — default to them rather than wiring a
16
+ bundle by hand:
17
+
18
+ - **`@oxy-hq/sdk`** — the data layer (`useQuery`, `useSemanticQuery`,
19
+ `useAgentRun`, `<OxyChat>`); see rule 2 and `oxy-hq/customer-apps:
20
+ docs/sdk-flow.md`.
21
+ - **`@oxy-hq/vite-plugin`** (`oxyApp()` in `vite.config.ts`) — bakes the asset
22
+ **base path** from the manifest, copies `oxy-app.json` into `out/`, proxies
23
+ `/api` in dev, and injects the `window.__OXY_APP__` shim. Dropping it
24
+ re-introduces the #1 bug — asset 404s → blank dashboard (rule 3).
25
+
26
+ Scaffold both with `@oxy-hq/create-oxy-app` or by copying `oxy-hq/customer-apps:
27
+ examples/hello-oxy/`. The
28
+ platform is kit-agnostic (any bundle with a correct base path works), but
29
+ hand-rolling means owning the base-path + dev-proxy wiring yourself.
30
+
31
+ (**`oxy-hq/customer-apps:` prefixes a path in that repo**, not in whatever repo
32
+ you are sitting in. This skill loads globally, so every such pointer is
33
+ qualified.)
34
+
35
+ ## Three rules
36
+
37
+ 1. **`oxy-app.json` (project root, not `public/`) is identity only.**
38
+ Schema:
39
+
40
+ ```json
41
+ {
42
+ "schemaVersion": 2,
43
+ "slug": "url-slug",
44
+ "orgSlug": "your-org",
45
+ "name": "Display Name"
46
+ }
47
+ ```
48
+
49
+ Required: `schemaVersion` (must be `2`), `slug` (non-empty).
50
+ Strongly recommended: `orgSlug` (lets `@oxy-hq/vite-plugin` derive
51
+ the asset base path automatically, so plain `pnpm build` produces
52
+ a bundle ready to drop into the linked org with no env var).
53
+ Optional: `name` (display), `projectId` (dev-time hint).
54
+
55
+ At serve time, oxy injects the authoritative org + project from
56
+ the admin row via `window.__OXY_APP__` — the manifest's `orgSlug`
57
+ and `projectId` are advisory hints only, never enforcement.
58
+
59
+ Do not invent `products`, `writers`, or `from:`-blocks. Those
60
+ don't exist in v2 and the probe rejects them.
61
+
62
+ File location: **`<root>/oxy-app.json`**, sibling to
63
+ `vite.config.ts`. The vite-plugin reads it from there; legacy
64
+ bundles that kept it under `public/` won't see the plugin's
65
+ base-path derivation kick in.
66
+
67
+ 2. **All data flows through SDK hooks → oxy HTTP endpoints.** The
68
+ surface is intentionally small:
69
+
70
+ | Hook / component | Endpoint | When |
71
+ | ---- | -------- | ---- |
72
+ | `useQuery({sql})` | `POST /api/projects/:id/query` | Raw SQL, 10k row cap |
73
+ | `useSemanticQuery({topic, …})` | `POST /api/projects/:id/semantic-query` | Reference topics/measures by name |
74
+ | `useAgentRun({agentId})` | `POST /api/projects/:id/agents/:id/asks` + SSE | Streaming chat / Q&A |
75
+ | `useProcedureRun({procedureId})` | `POST /api/projects/:id/procedures/:id/runs` ⚠️ beta | Long-running batch |
76
+ | `useFunction(name)` | `POST /customer-apps/<org>/<slug>/fn/<name>` + SSE | Invoke a server-side **Oxy Function** (see below) |
77
+ | `<OxyChat agentId="…"/>` | wraps `useAgentRun` | Drop-in chat UI |
78
+ | `<OxyAnswer {...run}/>` | renders any run | Drop-in answer renderer |
79
+
80
+ ```tsx
81
+ import { OxyChat, useQuery } from "@oxy-hq/sdk";
82
+ const { rows } = useQuery({
83
+ sql: "SELECT col FROM t WHERE x = {{ params.x | sqlquote }}",
84
+ }, { params: { x: someState } });
85
+ // ...
86
+ <OxyChat agentId="analytics" />
87
+ ```
88
+
89
+ See `oxy-hq/customer-apps: docs/sdk-flow.md` for the full reference,
90
+ including `OxyApiError` parsing and beta-surface caveats.
91
+
92
+ **Server-side work → Oxy Functions, not the browser.** `useQuery` &
93
+ friends run in the client; for work the browser shouldn't do
94
+ (warehouse writes, ELT kick-offs, external API calls, secret-bearing
95
+ logic) ship a **TypeScript handler under `functions/`** and invoke it
96
+ with `useFunction(name).invoke(body?)`. Declare it in `oxy-app.json`:
97
+
98
+ ```json
99
+ { "schemaVersion": 2, "slug": "my-app",
100
+ "functions": { "daily-rollup": { "route": true, "timeoutSeconds": 30 } } }
101
+ ```
102
+
103
+ ```ts
104
+ // functions/daily-rollup.ts — runs on Oxy's isolate runtime
105
+ export default async (req, ctx) => {
106
+ const { rows } = await ctx.query("SELECT … FROM …"); // data-plane ctx
107
+ return Response.json({ rows });
108
+ };
109
+ ```
110
+
111
+ `ctx` bridges to the data plane: `ctx.query` / `ctx.queryStream`,
112
+ `ctx.semantic.query`, `ctx.warehouse.{insert,exec,upsert}`,
113
+ `ctx.airway.run`, `ctx.fetch` (SSRF-allowlisted), `ctx.env`,
114
+ `ctx.user`, `ctx.log`. `oxy publish` bundles `functions/*.ts` with
115
+ esbuild — no separate backend to stand up. Worked example +
116
+ author-facing `ctx` types: `oxy-hq/customer-apps:
117
+ examples/hello-oxy/functions/`.
118
+
119
+ 3. **`OXY_APP_BASE_PATH` must match the path the bundle is linked
120
+ under.** `@oxy-hq/vite-plugin` resolves it from (in order):
121
+ `OXY_APP_BASE_PATH` env → `orgSlug + slug` in `oxy-app.json` →
122
+ `/`. `oxy publish` sets that env to `/customer-apps/<org>/<slug>/`
123
+ for the build automatically, and the vite-plugin derives the same
124
+ from the manifest for `pnpm dev` / `pnpm build`. Mismatch → asset
125
+ 404s → blank dashboard.
126
+
127
+ ## Four pitfalls
128
+
129
+ - **CORS errors are usually wrong-URL errors in disguise.** The SDK's
130
+ base URL must be `${OXY_URL}/api`, not `OXY_URL` bare. The API
131
+ router (which carries CORS headers) lives under `/api/*`. Hitting
132
+ `/<projectId>/...` lands in the static fallback and the missing
133
+ headers look like a CORS failure.
134
+
135
+ - **Cookie auth requires `credentials: "include"`.** The SDK's
136
+ `useQuery` sets this. If you fetch directly, set it yourself or the
137
+ `oxy_session` cookie won't be forwarded.
138
+
139
+ - **`projectId` in the manifest is advisory, not enforcement.** The
140
+ SDK uses it to construct the query URL. Authorization is the user's
141
+ cookie → user → org_member of the project's org, decided
142
+ server-side. A bundle that ships a stolen `projectId` can't read
143
+ data the user can't already read.
144
+
145
+ - **Cloud mode is the target.** Don't branch on local vs. cloud; don't
146
+ rely on the nil UUID `00000000-...` as a default project; don't
147
+ assume `OXY_STATE_DIR` is writable.
148
+
149
+ ## Performance guardrails
150
+
151
+ Any app that ships an Oxy Function or drives a batch of them should keep these —
152
+ cheap up front, hard to retrofit. (Established by the pokehouse `post-je` function.)
153
+
154
+ **In an Oxy Function — count the round-trips.** Each isolate run is stateless and
155
+ pays a fresh network hop per `ctx.query` / `ctx.warehouse.*`, so serial *dependent*
156
+ calls dominate latency.
157
+ - Run independent data-plane calls with `Promise.all`, not sequential `await`
158
+ (e.g. resolve reference data **and** provision tables concurrently — only the
159
+ final step needs both).
160
+ - Fold a check into a query that already runs: a duplicate/existence guard is a
161
+ scalar subquery on the main `SELECT`, not a second round-trip.
162
+ - `Promise.all` idempotent DDL (`CREATE TABLE IF NOT EXISTS`); better, keep
163
+ provisioning off the hot path if a migration can own it.
164
+ - A "cheap" extra `ctx.query`/`ctx.log` is N× the cost across N invocations.
165
+
166
+ **Driving many function calls from the UI (bulk actions) — don't go serial.**
167
+ - Independent calls run with **bounded concurrency** (~3–4) via a worker-pool over a
168
+ queue, not one-at-a-time — turns an O(N) wall-clock batch into a few waves.
169
+ - If the calls share mutable server state that races under concurrency (a rotating
170
+ token, a lazily-created table), the best fix is to **remove the shared mutation**
171
+ (e.g. a single-writer scheduled refresher owns the token; requests only read).
172
+ When you can't, warm the state with one call first, then fan out.
173
+ - Don't refetch derived state (a status ledger) after *every* item — the progress UI
174
+ is the live feedback; **throttle** the refetch (~1/s) and flush once at the end.
175
+ - Guard `setState` after the component may have unmounted mid-batch (`mounted` ref).
176
+
177
+ **Trust the server, not the client body.** Environment / tenant / which-secret
178
+ selection is derived server-side (a function secret, a server-side mapping table,
179
+ the authed identity), never from the request body — validate or override anything
180
+ the client sends that picks a target.
181
+
182
+ **Reuse the SDK's data-plane caching.** `useQuery` already dedups concurrent identical
183
+ queries and short-caches results — don't hand-roll a fetch that bypasses it. Mark
184
+ read-only functions `cache: { ttlSeconds }` in the manifest; **never** cache a
185
+ side-effecting one (it silently drops writes). `idempotencyKey` = a fresh UUID
186
+ per ATTEMPT (click), never a stable per-subject key: the server stores each
187
+ key's response indefinitely and replays it WITHOUT running the function, so a
188
+ `store:period` key silently breaks any post → compensate (delete) → post-again
189
+ cycle — the second post "succeeds" with the stale response and writes nothing.
190
+ The key shields one request from transport duplication; cross-attempt dedup
191
+ belongs in the function itself (a server-side guard over real state, e.g. a
192
+ posted-ledger row the compensating action clears).
193
+
194
+ **Watch-outs when optimizing (each surfaced in review):**
195
+ - **Clear UI running-state in `finally`.** An async batch driver must reset its
196
+ `running`/loading flag (and do its final refetch) in a `finally`, not after the
197
+ `await` — a stray throw otherwise leaves the bar stuck at "…" with the button
198
+ disabled.
199
+ - **Parallelizing can move a side effect ahead of a gate.** Running an independent
200
+ side-effecting call concurrently with a validation query means it can now fire
201
+ even when validation would reject. Fine if the side effect is benign — just know
202
+ you traded the ordering for the latency.
203
+ - **Additive migration for a scoped guard → backfill legacy rows to their TRUE value,
204
+ not a constant.** When a dedup/uniqueness guard starts keying on a new column,
205
+ `ADD COLUMN … DEFAULT <constant>` silently defeats the guard for pre-existing rows.
206
+ Derive the real value from an existing column (e.g. a DocNumber prefix) so old rows
207
+ stay correctly scoped.
208
+
209
+ ## Minimal starter
210
+
211
+ Easiest path: `pnpm dlx @oxy-hq/create-oxy-app@latest my-app --template vite`.
212
+ The scaffolded layout:
213
+
214
+ ```
215
+ my-app/
216
+ ├── oxy-app.json ← project root, not public/
217
+ ├── src/
218
+ │ ├── App.tsx
219
+ │ └── main.tsx
220
+ ├── package.json
221
+ ├── vite.config.ts
222
+ └── tsconfig.json
223
+ ```
224
+
225
+ `oxy-app.json`:
226
+ ```json
227
+ { "schemaVersion": 2, "slug": "my-app", "orgSlug": "test", "name": "My App" }
228
+ ```
229
+
230
+ `src/main.tsx`:
231
+ ```tsx
232
+ import { OxyAppProvider } from "@oxy-hq/sdk";
233
+ import { createRoot } from "react-dom/client";
234
+ import { App } from "./App";
235
+
236
+ createRoot(document.getElementById("root")!).render(
237
+ <OxyAppProvider><App /></OxyAppProvider>
238
+ );
239
+ ```
240
+
241
+ `src/App.tsx`:
242
+ ```tsx
243
+ import { useQuery } from "@oxy-hq/sdk";
244
+
245
+ export function App() {
246
+ const { rows, loading, error } = useQuery({
247
+ sql: "SELECT 1 AS hello",
248
+ });
249
+ if (loading) return <p>loading…</p>;
250
+ if (error) return <p style={{ color: "red" }}>{error.message}</p>;
251
+ return <pre>{JSON.stringify(rows, null, 2)}</pre>;
252
+ }
253
+ ```
254
+
255
+ `vite.config.ts`:
256
+ ```ts
257
+ import oxyApp from "@oxy-hq/vite-plugin";
258
+ import react from "@vitejs/plugin-react";
259
+ import { defineConfig } from "vite";
260
+
261
+ export default defineConfig({
262
+ plugins: [react(), oxyApp()],
263
+ });
264
+ ```
265
+
266
+ The plugin handles `outDir = "out"`, base-path resolution from the
267
+ manifest, dev-server proxy to `/api`, and the `window.__OXY_APP__`
268
+ dev shim. Plain `pnpm build` produces a bundle with the correct
269
+ asset base baked in.
270
+
271
+ ## Publishing
272
+
273
+ Ship with `oxy publish` — **no CI, no S3 sync, no folder picker**. From
274
+ the app directory (`oxy-hq/customer-apps: examples/hello-oxy/` is the worked
275
+ shell; note its manifest deliberately carries no `orgSlug`, leaving the org to
276
+ be resolved at publish time):
277
+
278
+ ```bash
279
+ oxy login --env production # once; browser flow, caches a token,
280
+ # and prints whether you're an app-admin
281
+ oxy publish --env production # build → tar → upload (draft channel)
282
+ oxy publish --env production --promote # …straight to live
283
+ ```
284
+
285
+ - **Identity** is the manifest's `slug` + `orgSlug` (override with
286
+ `--app` / `--org`). The server upserts the app row — there is no
287
+ separate `oxy apps create` step.
288
+ - **First publish of a new app** needs `--project <workspace-uuid>`: the
289
+ build-config lookup can't resolve a project until the app exists. Drop
290
+ the flag after the first publish — it's auto-resolved from then on.
291
+ - **`--env` targets:** `local` → the oxy web-app Vite dev server `:5173`
292
+ (run `pnpm dev` there; it serves `/cli-auth` and proxies `/api` to
293
+ oxy on `:3000`), `dev`/`staging`/`production` →
294
+ `app{-dev,-staging,}.oxygen-hq.com`.
295
+ - `--dir out` skips the build and uploads a prebuilt directory.
296
+ - Promote / roll back / unpublish from the oxy admin app-detail console;
297
+ every publish is audited (who shipped each build, when).
298
+
299
+ Full hands-on guide: `oxy-hq/customer-apps: docs/local-development.md`.
300
+
301
+ ## Reading data while vibe-coding
302
+
303
+ You don't need an `X-API-Key` to poke at the data API by hand — your
304
+ `oxy login` token authenticates as a bearer. From an app directory:
305
+
306
+ ```bash
307
+ oxyc login --env local # once; caches a token per host
308
+
309
+ # gh-api-style client (resolves the target + bearer for you, no headers):
310
+ oxyc api user --env local # who am I?
311
+ oxyc api projects/<projectId>/query --env local -f sql='select 1'
312
+
313
+ # raw curl with your token (e.g. to script or debug):
314
+ curl -H "Authorization: Bearer $(oxyc token --env local)" \
315
+ -H 'content-type: application/json' \
316
+ localhost:5173/api/projects/<projectId>/query -d '{"sql":"select 1"}'
317
+ ```
318
+
319
+ `oxyc api <path>` takes the path relative to `/api/`; `-X` sets the method
320
+ (POST when a body is given), `-f`/`-F` set the body (string / JSON-typed),
321
+ `--input` sends a raw one, `-i` includes response headers. `--env` resolves the
322
+ same targets as `oxy publish` (`local` → the Vite dev server `:5173`, which
323
+ proxies `/api` → oxy `:3000`).
324
+
325
+ Do not guess a path: `oxyc routes <filter>` lists what exists and
326
+ `oxyc schema <path>` gives the body it expects.
327
+
328
+ Access is your **normal org membership** — fine for your own test
329
+ workspace, not a cross-customer backdoor. The `projectId` is the one the
330
+ vite-plugin already resolved for the app (it's in `window.__OXY_APP__`).
@@ -0,0 +1,225 @@
1
+ ---
2
+ name: oxy-data-architecture
3
+ description: Use when deciding where an aggregate gets materialised — writing an `execute_sql` rollup, chaining one after an `airway` ingest step, choosing rollup vs semantic `pre_aggregations`, or whether raw rows must be retained — and on the symptoms: a dashboard that aggregates at query time, a load that wrote every row yet failed, preagg that cannot bucket to 15 minutes or join two views, or no "bronze"/"silver"/"gold" in the schema. Pipeline internals: `oxy-airway-pipelines`; view syntax: `oxy-semantic-modeling`.
4
+ ---
5
+
6
+ # Oxy data architecture: where results get materialised
7
+
8
+ An OLAP store wants **materialised results, not aggregation at query time.** A
9
+ dashboard that groups four million order rows on every page load is not a slow
10
+ query to tune — it is a rollup that was never built. So the work that produces
11
+ those tables has to be systematic: one place it runs, one guarantee about when it
12
+ may read, one rule for what gets kept.
13
+
14
+ That spans Airway ingest, the semantic layer, and scheduled automations, which is
15
+ why it is its own skill rather than a section in one of them — and it does not
16
+ restate them. Fold mechanics, duplicate rows and the pipeline lease are
17
+ **`oxy-airway-pipelines`**; views and measures **`oxy-semantic-modeling`**; task
18
+ syntax and schedules **`oxy-automations`**.
19
+
20
+ ## The source doc, and why half of it is not teachable
21
+
22
+ The frame comes from **`internal-docs/medallion-architecture.md`** in
23
+ **`oxy-hq/oxygen-internal`** — a **private** repo (often checked out under a
24
+ directory named `oxy-internal`). You cannot fetch it like a URL and it may not be
25
+ checked out at all where you are reading this, which is why every stable fact
26
+ below is carried here rather than pointed at. Do not copy more of it in: it is
27
+ actively maintained there, and a duplicate rots and then argues with the original.
28
+
29
+ **Its status is `design`, with an open "Decisions needed" section.** So this
30
+ skill splits it: **current state** is taught below as fact, re-verified against
31
+ source and cited; the **target tiering, gap list, and open decisions** are named
32
+ at the end and deliberately *not* taught. Building against a convention that does
33
+ not exist yet is worse than building against no convention at all.
34
+
35
+ ## The vocabulary bridge
36
+
37
+ **Nothing in the code says bronze, silver, gold, or medallion.** The doc
38
+ introduces that vocabulary as a *lens*, not as names you will find. Grep
39
+ `oxy-internal` for "bronze" or "silver" and you get Tailwind classes for
40
+ leaderboard medal ranks in the observability UI (`bg-rank-bronze`); grep "gold"
41
+ and you get golden-snapshot test names (`crates/oxy-compile/src/compile.rs:1042`)
42
+ and a restaurant in seed data. **Not one hit is a data tier.** An agent that
43
+ greps, finds nothing, and invents a tier structure has invented it.
44
+
45
+ | medallion word | what is actually there |
46
+ |---|---|
47
+ | (transient) bronze buffer | `<schema>_raw.<table>` append buffer — **`replacing` disposition only**, not a tier |
48
+ | **silver** | `<schema>.<table>`, the normalized read table — every disposition lands here |
49
+ | **gold** | `execute_sql` rollup tables (`*_daily_metrics`) **in the same schema** |
50
+ | the promotion | the **fold** (watermark-bounded, latest-wins, merge-on-read) — inline at end of load **and again** on the scheduled vacuum, identical SQL |
51
+ | storage | Airhouse = DuckLake on DuckDB (S3 Parquet + catalog) — **for pipelines that land in Airhouse** |
52
+
53
+ That last row is a default, not a law. A rollup lands wherever its `execute_sql`
54
+ step's `database:` points, so gold is not necessarily DuckLake: the canonical
55
+ exemplar view in `oxy-semantic-modeling` sits on a gold table
56
+ (`…___sales_daily_metrics`) whose `datasource:` is **`clickhouse`**. That view is
57
+ where the two skills meet — a view models a rollup an automation built — and it
58
+ is also the reminder to read the `database:`/`datasource:` rather than assume the
59
+ warehouse.
60
+
61
+ ## Current state: silver on ingest, gold in place
62
+
63
+ **Normalization happens at ingest, so the landed table is already silver.** It is
64
+ not a later stage. Airway flattens nested JSON into parent/child relational
65
+ tables, snake_cases every name (`normalizer/mod.rs:23`), coerces types, and
66
+ injects dlt-style lineage columns — `_aw_id`, `_aw_load_id`, `_aw_parent_id`,
67
+ `_aw_root_id`, `_aw_list_idx` (`normalizer/relational.rs:153`). There is no
68
+ separate "raw → typed" step to insert your own logic into.
69
+
70
+ **The `_raw` buffer is a write-throughput optimization, not a tier.** It exists
71
+ only for `write_disposition: replacing`, to keep the write path O(batch) instead
72
+ of scanning the target (airway `types.rs:117`). It is drained on fold, so steady
73
+ state is roughly empty — and when a fold fails the rows stay durable there,
74
+ which is what makes the re-run below safe rather than what makes the failure
75
+ harmless. **You cannot rebuild silver from it.**
76
+
77
+ > **Location trap.** The buffer lives at **`<schema>_raw.<table>`** — a sibling
78
+ > *schema*, where `<schema>` is the pipeline's `dataset_name`, so one landing in
79
+ > `toast_pos` buffers to `toast_pos_raw.orders` (airway
80
+ > `connector/destinations/airhouse.rs:1003`). Several in-tree comments still say
81
+ > `<table>_raw`, oxy's own `crates/agentic/airway/src/source_factory.rs:475`
82
+ > among them. That is the pre-`raw_schema_name` location, healed in place by
83
+ > `migrate_legacy_raw`; the comments never caught up. **This is not version
84
+ > drift** — the migration predates every airway rev oxy has pinned. Follow the
85
+ > comment and you look in the wrong schema and conclude the buffer is missing.
86
+
87
+ **Gold is a `CREATE OR REPLACE TABLE` in an automation**, reading the
88
+ Airway-landed tables and writing aggregates into the *same* dataset schema —
89
+ `toast_pos.orders_daily_metrics` beside `toast_pos.orders`. No marts schema
90
+ exists today. The `execute_sql` task:
91
+ <https://www.oxygen-hq.com/docs/guide/build/automations/task-types>
92
+
93
+ ## The sequencing guarantee — the mechanism that makes this systematic
94
+
95
+ This is the whole answer to "how do we do the background work systematically",
96
+ and it is one sentence in the source:
97
+
98
+ > A step completes only once the pipeline's end-of-load fold has committed, so a
99
+ > following `execute_sql` step reads a queryable table rather than a half-folded
100
+ > one.
101
+ > — `crates/core/src/config/model.rs:2435`
102
+
103
+ So ingest → rollup is a plain two-step automation, and the ordering is real
104
+ rather than hoped-for:
105
+
106
+ ```yaml
107
+ tasks:
108
+ - name: load
109
+ type: airway
110
+ pipeline: pipelines/restaurant_analytics.airway.yml
111
+ - name: rollup
112
+ type: execute_sql
113
+ database: pokehouse
114
+ sql_query: |
115
+ CREATE OR REPLACE TABLE toast_pos.orders_daily_metrics AS
116
+ SELECT business_date, restaurant_id, sum(net_amount) AS net_sales
117
+ FROM toast_pos.orders GROUP BY 1, 2
118
+ ```
119
+
120
+ Two things to know about that step:
121
+
122
+ - **`type: airway` is undocumented.** The task-types page above lists
123
+ `execute_sql` thoroughly and mentions airway **zero** times (checked
124
+ 2026-08-19). It is also not run by `step_executor` like the other I/O tasks —
125
+ it is delegated as a `TaskSpec::Airway` (`model.rs:2428`). Reading the docs
126
+ and concluding you must trigger ingest some other way is the expected mistake.
127
+ - **A failed fold fails the run — and the fix is to re-run, never to
128
+ re-extract.** Every row can be written and the load still fail, because the
129
+ fold that promotes them into the public table is a separate step: *"what
130
+ distinguishes 'written' from 'queryable'"*
131
+ (`crates/agentic/airway/src/events.rs:163`). Airway emits `LoadCompleted`
132
+ anyway, with correct counts and the per-table outcome in its `folds` field —
133
+ the counts are real — *"and the run reports an error"* (`events.rs:153`). It
134
+ then returns `Err`, which is *"what makes the RUN terminal state failed"*
135
+ (airway `pipeline/mod.rs:1378`; the returns at `:1376` and `:1881`,
136
+ `TaskOutcome::Failed` at `crates/agentic/airway/src/worker.rs:311`). So the
137
+ run goes red and the message names the cause outright: *"load wrote every row
138
+ but N table(s) failed the end-of-load fold … rows are durable in the staging
139
+ buffer but not yet visible in the public schema; re-run to fold them"*
140
+ (`pipeline/mod.rs:1291`). Take that literally. Cursors already advanced, so
141
+ *"the re-run re-extracts nothing and the fold sweeps the pending buffer"*
142
+ (`pipeline/mod.rs:1372`) — a full re-extract, or a Reset schema, is the
143
+ expensive wrong move on a run that already landed every row.
144
+
145
+ ## Rollup is not preagg
146
+
147
+ Asked to make a slow dashboard fast, an agent working in the semantic layer
148
+ reaches for `pre_aggregations`. Nothing warns it off: `pre_aggregations` appears
149
+ in no docs page, and `oxy-semantic-modeling` does not mention it either — so the
150
+ first thing that tells you it is the wrong tool is the SQL failing. It is a
151
+ **different mechanism** and it cannot do what a rollup does:
152
+
153
+ | | `execute_sql` rollup | semantic `pre_aggregations` |
154
+ |---|---|---|
155
+ | lives in | an automation | a `.view.yml` block |
156
+ | reads | any SQL — joins, CTEs, window functions | **one view**: `generate_build_sql` takes a single `view.source_sql()` and emits no JOIN (airlayer `engine/preagg.rs:266`) |
157
+ | time buckets | anything you can write | a calendar part only — `granularity` is interpolated straight into `date_trunc('{g}', …)` (airlayer `dialect/mod.rs:75`), so `year`/`quarter`/`month`/`week`/`day`/`hour`/`minute`/`second` and nothing between |
158
+ | refresh | your automation's schedule | `refresh_key`, by a background worker |
159
+
160
+ So **15-minute buckets and any two-table join must be a rollup.** `RollupSpec`
161
+ carries `dimensions`, `measures`, `time_dimension`, `granularity` — there is
162
+ nowhere to name a second view. Use preagg for the narrow case it fits: one view,
163
+ calendar grain, speeding up a measure that already resolves. Use a rollup for
164
+ everything else, which in practice is most gold.
165
+
166
+ ## Bronze policy is a decision rule, not a preference
167
+
168
+ The right policy is a function of **re-extractability**, and it is chosen per
169
+ domain rather than applied uniformly:
170
+
171
+ | source | policy | why |
172
+ |---|---|---|
173
+ | **re-extractable** — Toast, QuickBooks, most APIs | transient `_raw` buffer is fine; **the source system is the bronze** | you can always re-pull to rebuild silver; a durable warehouse copy is redundant cost |
174
+ | **non-re-extractable** — video events, sensor streams | the raw event table **is durable bronze and must be retained** | you cannot re-pull yesterday's frames; drop the events and the history is gone |
175
+
176
+ The video domain is the worked example, and it diverges visibly: `oxy_cam_events`
177
+ is written by a direct append (`crates/cameras/src/service/ingest.rs:106`) into a
178
+ plain table with no `_raw` buffer and no fold
179
+ (`crates/cameras/src/airhouse/schema.rs:16`) — it never goes through Airway at
180
+ all. That is the shape durable bronze takes here. Before proposing a retention or
181
+ cleanup job on any raw table, run the test above; the answer for cameras is the
182
+ opposite of the answer for Toast.
183
+
184
+ ## Why the shapes are these shapes: DuckLake
185
+
186
+ `PRIMARY KEY`, `UNIQUE`, and `CREATE INDEX` are all rejected
187
+ (`crates/cameras/src/airhouse/schema.rs:6`). That single constraint is why
188
+ `merge` stages then `MERGE INTO` and why `replacing` dedups on fold — no
189
+ `ON CONFLICT` to lean on, and scan pruning comes from partition/sort, not an
190
+ index. **`oxy-airway-pipelines` owns the consequences** (duplicate rows, the
191
+ single-flight lease, reset); do not re-derive them here. The one that reaches
192
+ gold: DDL in an `execute_sql` rollup must carry no constraints either.
193
+
194
+ **A corollary for rollup SQL:** join parent to child on the **propagated
195
+ business key** (`invoice_id` on `invoices__line`), never on `_aw_parent_id` — it
196
+ is regenerated every load (airway `types.rs:114`), so a join on it matches within
197
+ one load and silently stops matching across loads.
198
+
199
+ ## Proposed, not built — cite, do not build against
200
+
201
+ The rest of that doc is a roadmap. Say so out loud if someone asks you to follow
202
+ it; none of the following exists:
203
+
204
+ - **A conformance layer** — shared `dim_store` / `dim_date` / `dim_camera` and
205
+ cross-source id maps, materialised once. Cross-source keys are wired ad hoc
206
+ per domain today, mostly hand-maintained `VALUES` tables. One camera dimension
207
+ has since shipped in the video domain: proof the gap is real, **not** a
208
+ convention to copy.
209
+ - **Separating gold from silver** by schema or a `*_metrics` suffix tooling
210
+ treats as derived. They co-mingle today, and whether to adopt a convention now
211
+ or wait for a second consumer is an **open decision** in the doc.
212
+ - **Codifying the re-extractability test as policy** — the rule above is
213
+ reasoning that has held, not something enforced anywhere.
214
+
215
+ If a task needs one of these, that is a design conversation with the doc's
216
+ owners, not a pattern to invent in a customer workspace.
217
+
218
+ ## Version anchor
219
+
220
+ Checked 2026-08-19 against oxy-internal **0.5.126**, airway **0.1.32**
221
+ (`55e2794`), airlayer **0.3.6** (`7e884e77`) — both pinned in `Cargo.lock`.
222
+ Unprefixed `src/…` paths above are inside those external crates, not
223
+ oxy-internal; `crates/…` paths are oxy-internal. Both engines are fetched by git
224
+ rev, so a bump can move this ground with no oxy-internal commit — re-check
225
+ against the pin in `Cargo.lock`, not against memory.