@oxy-hq/cli 0.7.0 → 0.9.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 (38) hide show
  1. package/README.md +131 -8
  2. package/dist/main.mjs +4909 -3551
  3. package/dist/main.mjs.map +1 -1
  4. package/dist/{mcp-xxWL3_dz.mjs → mcp-Bdp0rX0l.mjs} +2 -2
  5. package/dist/{mcp-xxWL3_dz.mjs.map → mcp-Bdp0rX0l.mjs.map} +1 -1
  6. package/package.json +5 -5
  7. package/skills/oxy-agents/SKILL.md +318 -0
  8. package/skills/oxy-agents/references/quick-reference.md +222 -0
  9. package/skills/oxy-agents/templates/agentic-template.yml +141 -0
  10. package/skills/oxy-airway-pipelines/SKILL.md +223 -316
  11. package/skills/oxy-automations/SKILL.md +61 -15
  12. package/skills/oxy-cli/SKILL.md +37 -4
  13. package/skills/oxy-custom-apps/SKILL.md +193 -291
  14. package/skills/oxy-custom-apps/references/minimal-starter.md +63 -0
  15. package/skills/oxy-custom-apps/references/performance-and-usage.md +105 -0
  16. package/skills/oxy-custom-apps/references/reading-data.md +106 -0
  17. package/skills/oxy-custom-apps/references/sandbox-agent-loop.md +267 -0
  18. package/skills/oxy-custom-apps/references/verifying-in-the-browser.md +65 -0
  19. package/skills/oxy-data-apps/SKILL.md +362 -0
  20. package/skills/oxy-data-apps/references/examples.md +263 -0
  21. package/skills/oxy-data-apps/references/quick-reference.md +171 -0
  22. package/skills/oxy-data-apps/templates/dashboard-with-controls.app.yml +128 -0
  23. package/skills/oxy-data-apps/templates/simple-dashboard.app.yml +102 -0
  24. package/skills/oxy-data-architecture/SKILL.md +137 -152
  25. package/skills/oxy-evals/SKILL.md +362 -0
  26. package/skills/oxy-evals/references/diagnosis.md +243 -0
  27. package/skills/oxy-evals/references/results-json.md +145 -0
  28. package/skills/oxy-evals/references/writing-expected.md +112 -0
  29. package/skills/oxy-semantic-modeling/SKILL.md +239 -59
  30. package/skills/oxy-semantic-modeling/references/warehouse-casts.md +57 -0
  31. package/skills/oxy-semantic-modeling/templates/topic-template.yml +38 -0
  32. package/skills/oxy-semantic-modeling/templates/view-template.yml +119 -0
  33. package/template/.github/workflows/publish.yaml +15 -13
  34. package/template/.nvmrc +1 -1
  35. package/template/.oxyc-managed +5 -5
  36. package/template/README.md +21 -10
  37. package/template/package.json +3 -2
  38. package/template/pnpm-workspace.yaml +28 -17
@@ -1,338 +1,245 @@
1
1
  ---
2
2
  name: oxy-airway-pipelines
3
- description: Use when writing, reviewing, or debugging an Oxy Airway ELT pipeline (`.airway.yml`) — sources, the `kind` values Oxy actually wires, credentials, reset and retry — or when one misbehaves: "unsupported source kind", a pipeline that parses then fails at run time, credentials that do not resolve, a *pipeline* run landing `completed_with_errors` (an *automation* run with that status is `oxy-automations`), a schema that will not change, or duplicate rows. Airway has no published docs; this skill is the reference.
3
+ description: >-
4
+ Use when writing, reviewing, or debugging an Oxy Airway ELT pipeline (`.airway.yml`) — sources,
5
+ the `kind` values Oxy actually wires, credentials, reset, rewind and retry — or when one
6
+ misbehaves: "unsupported source kind", a pipeline that parses then fails at run time,
7
+ credentials that do not resolve, a *pipeline* run landing `completed_with_errors` (an
8
+ *automation* run with that status is `oxy-automations`), a schema that will not change, missing
9
+ history after a backfill, or duplicate rows. Carries the traps the published pipeline reference
10
+ does not.
4
11
  ---
5
12
 
6
13
  # Oxy Airway pipelines
7
14
 
8
- **Unlike every other Oxy subsystem, Airway has no published documentation** — no
9
- `airway.mdx`, no `.airway.yml` reference page, no JSON schema. So this skill
10
- carries content instead of pointing at a doc, which also makes it the
11
- fastest-rotting thing in this set. Treat it as a map, not the territory.
12
-
13
- ## Version anchor, and what is actually authoritative
14
-
15
- Everything below was read on 2026-08-17/18 against:
16
-
17
- | | |
18
- |---|---|
19
- | `oxy-internal` workspace version | **0.5.126** (`Cargo.toml:522`) |
20
- | `airway` engine dependency | git tag **0.1.30** (declared in `Cargo.toml`, the `airway = { git = …, tag = … }` line — `:509` today) resolving to rev `7c1737af9e69e0f9ad4e7a26a7969e4d1309538a` (the rev lives **only** in `Cargo.lock`, `[[package]] name = "airway"` — `:731` today; `Cargo.toml` never names it) |
21
- | Real corpus | Poke House's 19 `oxy/pipelines/*.airway.yml` |
22
-
23
- **Bumped since.** On 2026-08-19 `Cargo.lock` reads airway **0.1.32**
24
- (`55e2794490ebcfffdf69a9693a6044efe313d702`). Everything below was read at
25
- 0.1.30 and wants re-checking against the live `source_factory.rs`. One error has
26
- since been corrected in place (the `replacing` buffer's location, under
27
- duplicate rows) — note that it was **not** caused by the bump: it was wrong at
28
- 0.1.30 too. A stale claim here is at least as likely to be a misreading as it is
29
- to be drift; check which before blaming the pin.
30
-
31
- Airway is an **external engine fetched by git tag**, not a vendored copy —
32
- `crates/agentic/airway` is only Oxy's wiring around it. So a dependency bump can
33
- change which `kind`s work, and how they behave, with no oxy-internal commit at
34
- all. Re-check `Cargo.lock` before trusting any list here.
35
-
36
- **Two files are authoritative. When they disagree with this skill, they win:**
37
-
38
- - **`crates/agentic/airway/src/config.rs`** — the parser. Which keys exist,
39
- which are required, what the defaults are.
40
- - **`crates/agentic/airway/src/source_factory.rs`** — the dispatch. Which
41
- `kind` values Oxy can actually build. (Changing that crate rather than using
42
- it? Read its `CLAUDE.md` first.)
43
-
44
- **How the "no published doc" claim was established, so you can redo it:** the
45
- live docs sitemap — <https://www.oxygen-hq.com/docs/sitemap.xml> — carries 146
46
- URLs, **zero** mentioning airway, and `docs/build/data-infrastructure/` covers
47
- three of the four similarly-named engines (Airform, airlayer, Airhouse) but not
48
- this one. The single docs page whose URL says "pipeline",
49
- <https://www.oxygen-hq.com/docs/guide/build/agents/pipeline>, is a same-word
50
- false positive documenting the **agentic reasoning FSM**, not ELT. Do not send
51
- anyone there for Airway.
52
-
53
- ## Anatomy
54
-
55
- `AirwayPipelineSpec` (`config.rs`) is `#[serde(deny_unknown_fields)]`, so an
56
- unrecognised top-level key is a **hard parse error**, not a silent no-op. That
57
- is the friendly half; the traps below are the rest.
58
-
59
- | Key | Required | Default | Notes |
60
- |---|---|---|---|
61
- | `name` | **yes** | — | validated non-empty |
62
- | `source` | **yes** | — | `{kind: String, config: <free-form>}` |
63
- | `destination` | **yes** | — | `{database, dataset_name, schema_separator?}` |
64
- | `description` | no | — | free text; `>` block scalar is house style |
65
- | `resources` | no | `[]` = all | subset of the source's advertised resources |
66
- | `concurrency` | no | **`1`** | validated, capped at `MAX_CONCURRENCY = 16` |
67
- | `allow_concurrent_runs` | no | `false` | single-flight; see run semantics below |
68
- | `streaming` | no | `true` | falls back to the bulk path transparently |
69
- | `channel_capacity` | no | `2 * concurrency` | streaming path only |
70
-
71
- The corpus's smallest file (`quickbooks_financials.airway.yml`; IDs redacted and
72
- comments stripped — the real file's comments cover `database:` resolving
73
- credentials at run time via `airhouse_managed` ephemeral creds, plus commented
74
- `resources:` / `streaming:` exemplars):
75
-
76
- ```yaml
77
- name: quickbooks_financials
78
- source:
79
- kind: quickbooks
80
- config:
81
- client_id: <literal> # not a secret; see credentials below
82
- client_secret_var: QB_CLIENT_SECRET
83
- refresh_token_var: QB_REFRESH_TOKEN
84
- realm_id: "<literal>"
85
- destination:
86
- database: pokehouse
87
- dataset_name: quickbooks_financials
88
- concurrency: 1
89
- ```
90
-
91
- **All 19 real files use that `destination: {database, dataset_name}` reference
92
- form**, resolved by the `agentic-pipeline` executor at run time. `config.rs` also
93
- accepts an `Inline{kind, config}` destination, but its own doc comment calls that
94
- a test-fixture / post-resolution shape — no real file uses it. Field presence
95
- across the 19: `name`/`source`/`destination` 19, `concurrency` 14, `description`
96
- 12, `schema_separator` 1, and `resources` / `allow_concurrent_runs` / `streaming`
97
- / `channel_capacity` **0** — those four are off the beaten path.
98
-
99
- **The extension is `.airway.yml`, and it is load-bearing.** All 19 use it; none
100
- uses a bare `.yml`. The compile-boundary walker
101
- (`crates/oxy-compile/src/walker.rs:170`) globs literally `**/*.airway.yml` to
102
- produce `FileKind::AirwayPipeline` — a `pipelines/foo.yml` is not a broken
103
- pipeline, it is *invisible to compilation*, with no error to explain it.
104
-
105
- ## The wired `kind` set — the trap that costs the most time
106
-
107
- **Oxy wires a strict subset of the engine's sources.** The upstream airway crate
108
- registers roughly **40** source modules; `source_factory.rs` gives **14** of them
109
- a concrete arm. YAML has no `kind` enum — `SourceConfig.kind` is a plain `String`
110
- — so `kind: shopify` parses perfectly and only then fails at *run time*, in the
111
- `other =>` fallback arm of the dispatch `match` inside
112
- `source_factory.rs`'s `build_source_connector_inner`, with
113
- ``unsupported source kind `shopify`. Wire it up in
114
- agentic_airway::source_factory::build_source_connector — …`` (cited by symbol,
115
- not line: that arm is the *last* one in the table, so every source added above
116
- it shifts it. Two did on 2026-08-17 alone — `ubereats` (#2937) and `netsuite`
117
- (#2939).)
118
-
119
- The 14 wired kinds, and how hard the real corpus exercises each:
120
-
121
- | `kind` | Real pipelines using it |
122
- |---|---|
123
- | `http_file` | 5 (census LODES + gazetteer pulls) |
124
- | `quickbooks` | 5 (see below — not one pattern) |
125
- | `rest_api` | 3 (`census_acs`, `nces_schools`, `yelp_fusion`) |
126
- | `clickhouse` | 2 (`clickhouse_ingest`, `clickhouse_mirror`) |
127
- | `besttime` | 1 |
128
- | `overpass` | 1 (`osm_augmentation`) |
129
- | `overture` | 1 (`overture_places`) |
130
- | `toast` | 1 (`restaurant_analytics`) |
131
- | `filesystem`, `sql_database`, `postgres_cdc`, `weather`, `netsuite`, `ubereats` | 0 — wired, never exercised here |
132
-
133
- Registered upstream but **not** wired here, so unavailable today: `shopify`,
15
+ **The reference is upstream — read it first:**
16
+ <https://www.oxygen-hq.com/docs/guide/build/data-infrastructure/airway/pipelines>
17
+ (keys, supported `kind` values, credentials, how a run works), with the
18
+ overview beside it at
19
+ <https://www.oxygen-hq.com/docs/guide/build/data-infrastructure/airway>.
20
+
21
+ That page owns the key table and the basic run model. This skill does not
22
+ restate them; it carries the traps that cost time, from one production
23
+ workspace's 19 pipelines.
24
+
25
+ ## A pinned engine, so the wired set moves
26
+
27
+ Airway is an external engine. Each Oxy release pins one engine version and
28
+ gives a concrete connector to a **subset** of the sources that engine ships.
29
+ The lists below were checked against the engine Oxy pinned in October 2026
30
+ (airway 0.1.51). A newer Oxy may wire more; when this page and a run disagree,
31
+ the run's `unsupported source kind` error is the authority.
32
+
33
+ ## Anatomy traps
34
+
35
+ - **Strict at the top, strict-later inside `config:`.** An unknown top-level
36
+ key is a parse error. The `config:` block is free-form when the file is
37
+ parsed and checked only when a run builds the connector — and most kinds
38
+ then reject an unknown key. So a typo inside `config:` sails through
39
+ compilation and fails the first run with `invalid <kind> config: unknown
40
+ field`.
41
+ - **Use the reference destination form** — `destination: { database,
42
+ dataset_name }`, resolved to a `config.yml` database at run time. An inline
43
+ destination shape also parses, but it exists for tests; no real pipeline uses
44
+ it.
45
+ - **`name` must not start with `preview:`** — the prefix is reserved and
46
+ refused.
47
+ - **Off the beaten path:** across 19 real files, `resources`,
48
+ `allow_concurrent_runs`, `streaming` and `channel_capacity` appear **zero**
49
+ times. They work, but there is no house precedent to copy.
50
+
51
+ ## Source kinds: what is wired
52
+
53
+ **Wired (16):** `rest_api`, `http_file`, `filesystem`, `google_sheets`,
54
+ `sql_database`, `postgres_cdc`, `clickhouse`, `toast`, `ubereats`, `sp_api`,
55
+ `quickbooks`, `netsuite`, `weather`, `besttime`, `overture`, `overpass`.
56
+
57
+ **Shipped by the engine but not wired — unavailable in Oxy:** `shopify`,
134
58
  `stripe`, `github`, `hubspot`, `salesforce`, `slack`, `jira`, `notion`,
135
- `zendesk`, `google_ads`, `google_analytics`, `google_sheets`, `facebook_ads`,
136
- `airtable`, `mongodb`, `kafka`, `kinesis`, and more. **Reading airway's own
137
- source list and concluding a `kind` works here is the most expensive mistake
138
- this file format offers.** Check the live `match` arms instead.
139
-
140
- **`rest_api` is the generic escape hatch.** Per `source_factory.rs`'s own doc
141
- comment, the vendor-specific helpers (`shopify`, `github`, `stripe`, …) are all
142
- built on `RestApiSource` upstream, so most can be expressed directly as a
143
- `rest_api` config; per-vendor sugar is deferred until a real consumer asks. A
144
- source with no dedicated arm is a `rest_api` config, not a blocker.
145
-
146
- Destinations are narrower still — `destination_factory.rs` wires exactly three:
147
- `memory` (test fixture), `airhouse` (production; where all 19 real pipelines
148
- land), and `postgres`.
149
-
150
- ### `quickbooks` is not a normal match arm
151
-
152
- It is dispatched by an **early return before the dispatch table**
153
- (`source_factory.rs:229`), ahead of the `match` at line 236, because it is the
154
- one source needing plumbing the host must supply: the `RefreshTokenSink` and
155
- `AccessTokenSource` traits (`source_factory.rs:61` / `:106`), threaded in as
156
- `QuickBooksTokens`. Two mutually exclusive custody modes fall out of that, and
157
- `build_quickbooks` **fails closed** if a config mixes them:
158
-
159
- - **Rotating custody** — `client_secret_var` + `refresh_token_var`: the pipeline
160
- refreshes against Intuit itself and writes the new token back through the sink.
59
+ `zendesk`, `google_ads`, `google_analytics`, `facebook_ads`, `airtable`,
60
+ `mongodb`, `kafka`, `kinesis`, and more. `kind` is a free-text field, so
61
+ `kind: shopify` parses perfectly and fails only when the pipeline runs.
62
+ **Reading the engine's own source list and concluding a `kind` works here is
63
+ the most expensive mistake this file format offers.**
64
+
65
+ **`rest_api` is the escape hatch.** The engine's vendor connectors (`shopify`,
66
+ `github`, `stripe`, …) are built on its generic REST source, so most can be
67
+ written directly as a `rest_api` config with its own pagination and `auth`
68
+ settings. A source with no dedicated kind is a `rest_api` config, not a blocker.
69
+
70
+ **No Excel.** `filesystem` reads `json`, `jsonl` and `csv` only (from a local
71
+ path or `s3://`, `gs://`, `az://` plus a glob `pattern`); `ubereats` reads its
72
+ `.csv` reports only; `http_file` downloads a file over HTTP. An `.xlsx`
73
+ workbook has **no Airway path** — read as CSV it is binary noise. Export it to
74
+ CSV, or keep the data in a Google Sheet and use `google_sheets`.
75
+
76
+ **Destinations** are `airhouse` (production — where every real pipeline lands)
77
+ and `postgres`; a `memory` destination exists for tests only.
78
+
79
+ ### `quickbooks`: two custody modes, never mixed
80
+
81
+ QuickBooks is the one source whose token the host must plumb in, and it comes
82
+ in two mutually exclusive custody modes. A config that mixes them **fails
83
+ closed** rather than guessing:
84
+
85
+ - **Rotating custody** — `client_secret_var` + `refresh_token_var`: the
86
+ pipeline refreshes against Intuit itself and stores the new refresh token.
161
87
  - **Read-only custody** — `access_token_var` alone: some *other* component owns
162
88
  rotation, and the value is re-read per request rather than substituted once,
163
- since a 60-minute token pinned at the start of a long backfill expires mid-run.
164
-
165
- **Why that check exists, and why it matters far beyond this file:** Intuit voids
166
- the old refresh token whenever it issues a new one, so two components rotating
167
- the same grant deadlock into `invalid_grant` — each invalidating the other's —
168
- until a human re-authorises by hand. Exactly one component may ever hold rotating
169
- custody; `access_token_var` is how you keep a pipeline out of that role. The
170
- source comment says it outright: a second refresher "would fork this grant's
171
- rotation chain."
172
-
173
- Hence the five real QuickBooks pipelines are **not one pattern**. The base
174
- `quickbooks_financials.airway.yml` is rotating; the four per-location files
175
- (`_clovis`, `_eastbay`, `_lakeoswego`, `_santarosa`) are read-only, fenced from
176
- Intuit's token endpoint because a scheduled Oxy Function owns rotation. Copying
177
- "the QuickBooks pipeline" as a template for a new company means first knowing
178
- which custody mode that company's secret was provisioned under.
179
-
180
- ## Credentials live in the secret manager, never in the YAML
181
-
182
- The convention is a `*_var` suffix naming a secret **key**, never a value;
183
- `crates/agentic/pipeline/src/executor/mod.rs` (`resolve_airway_source_secrets`)
184
- holds the per-kind `(field, var_key)` table.
185
-
186
- - Flat configs. **The per-kind sets are disjoint — read them per row, never as
187
- a shared menu:**
188
-
189
- | `kind` | `*_var` keys it manages |
190
- |---|---|
191
- | `toast` | `client_secret_var`, `client_id_var` |
192
- | `quickbooks` | `client_secret_var`, `refresh_token_var` (plus `access_token_var` — see below, resolved differently) |
193
- | `netsuite` | `private_key_var` |
194
- | `clickhouse` | `password_var` |
195
- | `weather`, `besttime` | `api_key_var` |
196
-
197
- A `kind` with no arm in that `match` carries no managed credentials at all.
198
- Borrowing across rows fails, and not gently: put `client_id_var` on a
199
- quickbooks config and the executor leaves it in place (its quickbooks arm
200
- strips only the two it owns), so it reaches `QuickBooksParams` — which is
201
- itself `#[serde(deny_unknown_fields)]` and has no such field. Unlike the
202
- top-level `deny_unknown_fields` at anatomy above, this one bites when the
203
- connector is **built**, i.e. at run time: a spec that parsed cleanly dies on
204
- `invalid quickbooks config: unknown field`. Same for `refresh_token_var` on a
205
- toast config.
206
- - **`rest_api` nests differently** — auth lives under `config.auth`, handled by
207
- a separate `resolve_rest_api_auth_secrets`, as `token_var` (bearer) or
208
- `key_var` (`api_key` header/query). Real, from `census_acs.airway.yml`:
209
-
210
- ```yaml
211
- auth:
212
- type: api_key_query
213
- key_var: CENSUS_API_KEY
214
- param: key
215
- ```
216
-
217
- The executor resolves `*_var` → literal and then **strips the `_var` key**, so
218
- the connector factory (and airway itself) only sees the resolved value under the
219
- plain name — `client_secret_var` becomes `client_secret`. Hence **`password_var`
220
- is not an accepted field on `ClickHouseParams`**, nor any other pair: the `_var`
221
- form is valid *only* at the YAML layer.
89
+ since a 60-minute token pinned at the start of a long backfill expires
90
+ mid-run.
91
+
92
+ **Why that matters far beyond this file:** Intuit voids the old refresh token
93
+ whenever it issues a new one, so two components rotating the same grant
94
+ deadlock into `invalid_grant` — each invalidating the other's — until a human
95
+ re-authorises by hand. Exactly one component may ever hold rotating custody;
96
+ `access_token_var` is how you keep a pipeline out of that role.
97
+
98
+ Hence QuickBooks pipelines in one workspace are often **not one pattern**: a
99
+ base pipeline that rotates, and per-location pipelines that are read-only
100
+ because a scheduled job owns rotation. Copying "the QuickBooks pipeline" as a
101
+ template means first knowing which custody mode that company's secret was
102
+ provisioned under.
103
+
104
+ ### `google_sheets`: store the key, never a token
105
+
106
+ - Set **`service_account_json_var`** to a secret holding the service-account
107
+ **JSON key**. A fresh, read-only access token is minted from it on every run.
108
+ Do not store an access token: it lives an hour, so the pipeline would succeed
109
+ once and then return 401 forever. With no key resolved, the run fails with
110
+ `no access token was resolved` rather than a misleading "not found".
111
+ - **`ranges:` decides the resource names.** Each entry (`Main`, `Main!A:S`)
112
+ becomes one resource named after the part before `!`, lowercased, spaces
113
+ turned into underscores — `Main!A:S` is the resource `main`, and that is the
114
+ name `resources:` has to list. Empty `ranges` loads every sheet as one
115
+ resource called `sheet_data`.
116
+
117
+ ### `sp_api` (Amazon): two settings with no safe default
118
+
119
+ - **`default_start` is the entire backfill policy.** The connector only pulls
120
+ *forward*, from one window spanning `default_start` to now, with no
121
+ chunking. Too recent loses history *quietly* — nothing signals it, and only a
122
+ cursor rewind brings it back. Too far back is worse: a window too large for
123
+ Amazon to build in time fails identically on every subsequent run, so the
124
+ pipeline stalls permanently instead of catching up. Pick it deliberately.
125
+ - **`partner_type` (`seller`, the default, or `vendor`) belongs to the
126
+ credential.** It decides which reports exist at all; the wrong value pulls
127
+ nothing and refuses every named resource. A business with both accounts
128
+ needs two pipelines.
129
+
130
+ ## Credentials: per-kind `*_var` keys
131
+
132
+ A `*_var` key names a secret **key**, never a value; the run resolves it from
133
+ the secret manager, writes the value under the plain name (`client_secret_var`
134
+ → `client_secret`), and removes the `_var` key. **The sets are per kind and do
135
+ not overlap — read the row for your kind, never borrow from another:**
136
+
137
+ | `kind` | `*_var` keys it manages |
138
+ |---|---|
139
+ | `toast` | `client_id_var`, `client_secret_var` |
140
+ | `quickbooks` | `client_secret_var`, `refresh_token_var`, `client_id_var` — or `access_token_var` alone (read-only custody) |
141
+ | `sp_api` | `client_secret_var`, `refresh_token_var` |
142
+ | `netsuite` | `private_key_var` — a multi-line PEM; the secret store must keep its newlines |
143
+ | `clickhouse` | `password_var` |
144
+ | `sql_database`, `postgres_cdc` | `connection_string_var` (a whole DSN) |
145
+ | `weather`, `besttime` | `api_key_var` |
146
+ | `google_sheets` | `service_account_json_var` |
147
+ | `rest_api` | nested under `config.auth`: `token_var` (bearer) or `key_var` (`api_key` header or query) |
148
+
149
+ Any other kind manages no credentials.
150
+
151
+ - **Borrowing across rows fails at run time, not at parse time.** Put
152
+ `refresh_token_var` on a `toast` config, or `client_id_var` on an `sp_api`
153
+ config, and nothing resolves or removes it; it reaches the connector's strict
154
+ config and the run dies on `unknown field` — in a file that compiled cleanly.
155
+ - **The `_var` form is valid only in the YAML.** The connector never accepts
156
+ `password_var` and the like; it only ever sees the resolved plain field.
157
+ - **An unresolvable secret name fails the run and names the secret. A secret
158
+ that resolves to an empty string counts as unset** — the field is left out
159
+ entirely (ClickHouse then sends no password at all, not an empty one).
222
160
 
223
161
  ## Run semantics
224
162
 
225
163
  - **Partial failures are their own status.** A run that loaded some resources
226
- and failed others records as **`completed_with_errors`**, not as a failure.
227
- This is airway-engine behaviour Oxy inherits — it lives in the pinned engine
228
- (`src/pipeline/mod.rs:743,1316,2993`, `src/airstack/mod.rs:313,352`), not in
229
- the wiring crate — so a dashboard or alert that only matches "failed" reports
230
- it as clean success.
231
- - **Schema migration is additive-only, structurally.** `schema/evolution.rs`
232
- (`diff_schemas` / `diff_tables`) emits only four changes: `TableAdded`,
233
- `ColumnAdded`, `VariantColumnCreated` (a type conflict creates a *new* variant
234
- column, leaving the old one alone), `WriteDispositionChanged` — no "column
235
- dropped", no "type changed in place". The enum cannot represent a narrowing
236
- change, so **a pipeline sitting on a wrong schema never self-heals, however
237
- many times you rerun it.**
238
- - **Reset schema is the explicit escape**, and a real mechanism:
239
- `crates/agentic/airway/src/reset.rs` drops the pipeline's destination tables
240
- and deletes its `airway_pipeline_state` row, so the next run re-infers a fresh
241
- schema. Exposed as an HTTP route (`crates/agentic/http/src/routes/airway.rs`)
242
- and a UI button (`web-app/src/components/airway/ResetSchemaButton.tsx`).
243
- Destructive on purpose.
244
- - **A retry resumes from a cursor; it does not re-pull history.** There are two
245
- cursors, and confusing them wastes time. The **pipeline-global** one is
246
- `airway_pipeline_state` (`state_store.rs`), keyed by `pipeline_name`: ordinary
247
- incremental runs advance it, ordinary retries reuse it, and Reset is the only
248
- sanctioned way to wipe it. Two runs racing that single row is half of why
249
- `allow_concurrent_runs` exists — see below for the other half. The **per-run**
250
- one is `airway_run_extensions.resume_state` (beside `retry_count`), used only
251
- by **backfill** runs; retry there is *reset-in-place*, re-driving the same
252
- `run_id` rather than cloning a new run (Toast:
253
- `with_resumable_backfill_window`).
164
+ and failed others records as **`completed_with_errors`**. A dashboard or
165
+ alert that only matches "failed" reports it as a clean success.
166
+ - **Schema migration is additive-only, structurally.** A run can add a table, a
167
+ column, or a *variant* column for a type conflict (the old column is left
168
+ alone); it can never drop or narrow a column in place. **A pipeline sitting
169
+ on a wrong schema never self-heals, however many times you rerun it.**
170
+ - **Two recoveries, and they are not interchangeable:**
171
+ - **Reset schema** drops the pipeline's destination tables and clears its
172
+ cursors, so the next run re-infers a fresh schema and re-pulls everything.
173
+ Destructive on purpose.
174
+ - **Rewind cursors** (all of them, or one resource's) keeps every table and
175
+ row and only moves the cursor back, so the next run re-pulls a window. It
176
+ is **refused** where re-pulling would duplicate rows — an append-only
177
+ table, or a merge without a key — unless explicitly forced. A
178
+ `replace` table, or a keyed merge, converges safely.
179
+ - **A retry resumes from the cursor; it does not re-pull history.** Cursors
180
+ are kept per workspace and pipeline, and advance only after a load commits,
181
+ so ordinary retries are gap-free. A backfill run keeps its own resumable
182
+ window and is retried in place rather than cloned.
183
+ - **A `modified`/`updated_at` cursor can lie.** Some sources change a record
184
+ without advancing its modified timestamp (Toast does not advance it when a
185
+ card payment is captured), so an incremental run never sees the change. A
186
+ backfill meant to repair that must widen **both** ends of its window — not
187
+ only the start — or it re-reads the same stale rows.
254
188
 
255
189
  ### Duplicate rows in a `replacing` table
256
190
 
257
191
  `write_disposition: replacing` never writes the served table directly — it
258
- appends to a buffer that a merge-on-read fold rebuilds latest-wins, chosen to
259
- avoid the O(target) `MERGE INTO` that OOMs the data plane (the
260
- `WriteDispositionLabel::Replacing` doc comment in `source_factory.rs`). **That
261
- fold runs twice over: inline at the end of every successful load, and again on
262
- the scheduled airhouse vacuum, from identical SQL.** Either can be the one that
263
- promotes a given row, which is why the failure below is about overlap rather
264
- than about the vacuum alone.
265
-
266
- **That doc comment names the buffer `<table>_raw`, and it is wrong** — the
267
- buffer is a sibling *schema*, `<schema>_raw.<table>`. So was an earlier version
268
- of this skill; the correction is not a version bump, since the migration
269
- predates every airway rev oxy has pinned, including 0.1.30. `oxy-data-architecture`
270
- carries the location, the stale comments, and where the buffer sits in the wider
271
- picture. That shape is where duplicates come from:
272
- each run ends with a merge-on-read fold, and **two folds whose snapshots overlap
273
- each purge against a base the other has not committed yet, so both versions of a
274
- changed row survive** (`extension/migration.rs:432`). Measured, not
275
- hypothetical — pokehouse, 2026-08-05: 34 excess rows in `toast_pos.orders`, 104
276
- in `order_selections`, **every duplicate pair spanning two `_aw_load_id`s**
277
- (`migration.rs:435`). That span is the diagnostic, so check it *before* auditing
278
- your own config: a pair straddling two load ids is this bug, not yours — and if
279
- it doesn't straddle, this section does not explain your duplicates.
280
-
281
- The guard is the single-flight lease `airway_pipeline_leases` — at most one
282
- active run per `(workspace_id, pipeline_name)`, taken by one atomic
283
- `INSERT … ON CONFLICT DO UPDATE … WHERE expires_at < now()` so the database
284
- resolves the race rather than application code (`extension/pipeline_lease.rs:1`,
285
- `:16`). A process-local mutex was rejected explicitly: Oxy runs multiple
286
- `oxy-serve`/`oxy-worker` replicas, and the engine's own `COMPACTION_GATE` rests
287
- on a single-writer assumption that stopped holding once the data plane scaled
288
- past one replica (`pipeline_lease.rs:7`). Two consequences follow.
289
- **`allow_concurrent_runs: true` turns the guard off** — `config.rs:44` says to
290
- set it only on a pipeline owning no cursor and writing no `replacing` table,
291
- since on a `replacing` pipeline it re-admits exactly this bug. And **a
292
- dead-lettered run never releases its lease**: the 6-hour `LEASE_TTL_SECS` is a
293
- crash backstop, not a reclaim policy, and the generic reaper cannot touch the
294
- table without leaking a domain table into `agentic-runtime`
295
- (`pipeline_lease.rs:73`, `:104`) — so a pipeline stuck "busy" with nothing
296
- running is that gap, and wants the manual release, not a six-hour wait.
192
+ appends to a buffer in a sibling *schema*, `<schema>_raw.<table>`, that a
193
+ merge-on-read fold rebuilds latest-wins (chosen to avoid a full-table
194
+ `MERGE INTO` that runs the data plane out of memory). **That fold runs twice
195
+ over: inline at the end of every successful load, and again on the scheduled
196
+ warehouse vacuum, from identical SQL.** Either can be the one that promotes a
197
+ given row.
198
+
199
+ That shape is where duplicates come from: **two folds whose snapshots overlap
200
+ each purge against a base the other has not committed yet, so both versions of
201
+ a changed row survive.** Measured, not hypothetical — dozens of excess rows in
202
+ a production orders table, **every duplicate pair spanning two
203
+ `_aw_load_id`s**. That span is the diagnostic, so check it *before* auditing
204
+ your own config: a pair straddling two load ids is this, not your config — and
205
+ if it does not straddle, this section does not explain your duplicates.
206
+
207
+ The guard is a **single-flight lease**: at most one active run per
208
+ (workspace, pipeline), taken atomically in the database so that replicas
209
+ racing each other cannot both win. Two consequences:
210
+
211
+ - **`allow_concurrent_runs: true` turns the guard off.** Set it only on a
212
+ pipeline that owns no cursor and writes no `replacing` table; on a
213
+ `replacing` pipeline it re-admits exactly this bug.
214
+ - **A dead-lettered run never releases its lease.** The lease's 6-hour expiry
215
+ is a crash backstop, not a reclaim policy, so a pipeline stuck "busy" with
216
+ nothing running is this gap — ask an operator to release the lease rather
217
+ than waiting six hours.
297
218
 
298
219
  The lease stops *new* overlapping folds; being an admission guard, it cannot
299
- repair rows that already landed. Those need Reset schema.
220
+ repair rows that already landed. Those need **Reset schema**.
300
221
 
301
222
  ## Traps
302
223
 
303
- **The `$schema` header several real pipelines carry is dead, and its failure is
304
- silent.** Four of the 19 (`quickbooks_financials_clovis`, `_eastbay`,
305
- `_lakeoswego`, `_santarosa`) open with a `# yaml-language-server: $schema=`
306
- comment pointing at
307
- `raw.githubusercontent.com/oxy-hq/oxy/refs/heads/main/json-schemas/pipeline.json`
308
- (scheme omitted deliberately — the link is dead and this repo's link check fails
309
- any non-200). Curled: **404**. `oxy-hq/oxy` was renamed to `oxy-hq/oxygen` and
310
- the path 404s there too; that repo's `json-schemas/` holds 8 files and **no
311
- `pipeline.json` — no schema for `.airway.yml` exists at all.** Start from a real
312
- pipeline and you inherit an editor integration that validates nothing, warns
313
- about nothing, and looks like it works. Delete the line. (The other 15 carry no
314
- pointer at all, so IDE validation is inconsistent across the corpus even before
315
- the link being dead.)
316
-
317
- **Omitting `concurrency` means `1`, not "unlimited".** The default is forced
318
- sequential and nothing announces it: the 5 files that omit it (the census
319
- `http_file` pulls) run serially, while the 14 that set it use 2–4. A pipeline
320
- that just forgets the key is not misconfigured — it is quietly slow.
321
-
322
- **Two `clickhouse` source footguns, both silent.** (a) `ClickHouseParams`' own
323
- doc comment admits that `port: 8443` with no `secure:` "silently uses plaintext
324
- on a TLS port… left as-is deliberately" — tolerable only because the wizard emits
325
- both keys together, and these are hand-edited files. State `secure:` explicitly
326
- and match it to the port, as `clickhouse_mirror.airway.yml` does (`8123`/`false`).
327
- (b) Socket timeouts belong in `settings:`, never a SQL `SETTINGS` clause:
328
- ClickHouse fixes the response socket's timeout at request setup, *before* parsing
329
- the query body, so an inline `SETTINGS http_send_timeout=…` is accepted and
330
- ignored — exactly what someone reaches for when tuning a slow mirror.
331
-
332
- **A `kind` claim is only as good as the pin it was checked against, and in-tree
333
- comments do not self-update.** `overture_places.airway.yml` still carries a
334
- header saying the connector has not landed and the airway rev must be pinned to
335
- a build registering `kind: overture` — which at 0.1.30 it does, so the comment
336
- has been stale for a while with nothing to flag it. Read that as the standing
337
- warning about this whole document: verify against `source_factory.rs` at the rev
338
- you are running.
224
+ **A `# yaml-language-server: $schema=` header on a pipeline is dead, and its
225
+ failure is silent.** Some real pipelines open with one pointing at a
226
+ `json-schemas/pipeline.json` on GitHub. That URL returns **404**, and no JSON
227
+ schema for `.airway.yml` is published anywhere. Start from such a file and you
228
+ inherit an editor integration that validates nothing, warns about nothing, and
229
+ looks like it works. Delete the line.
230
+
231
+ **Two `clickhouse` source footguns, both silent.** (a) `port: 8443` with no
232
+ `secure:` silently uses plaintext on a TLS port — tolerable only because the
233
+ setup wizard emits both keys together, and these are hand-edited files. State
234
+ `secure:` explicitly and match it to the port (`8123`/`false`,
235
+ `8443`/`true`). (b) Socket timeouts belong in the source's `settings:` map,
236
+ never in a SQL `SETTINGS` clause: ClickHouse fixes the response socket's
237
+ timeout at request setup, *before* parsing the query body, so an inline
238
+ `SETTINGS http_send_timeout=…` is accepted and ignored — exactly what someone
239
+ reaches for when tuning a slow mirror.
240
+
241
+ **Comments inside pipeline files do not self-update.** One real pipeline still
242
+ carries a header saying its connector "has not landed" and the engine must be
243
+ pinned to a build registering that `kind` — long after it was wired, with
244
+ nothing to flag the comment as stale. Treat every in-file claim about what is
245
+ supported, this page included, as dated; the run is the authority.