@yanlinglabs/winter-provider-catalog 0.0.1

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/PROVENANCE.md ADDED
@@ -0,0 +1,598 @@
1
+ # Provenance — how `generated/catalog.json` came to exist
2
+
3
+ ## Layers
4
+
5
+ The committed catalog is the merge of two layers, performed by `scripts/provider-catalog.ts`
6
+ (`bun run provider:catalog`):
7
+
8
+ | Layer | Source | Owner | Present |
9
+ | --- | --- | --- | --- |
10
+ | upstream | `generated/upstream-layer.json`, extracted from the pinned OmniRoute tree by `scripts/provider-source-sync.ts` | the extractor | **yes** — 106 providers, 540 models |
11
+ | overlay | `overlay/providers.json` + `overlay/models.json`, hand-authored and reviewed | Winter | yes — 64 providers, 65 models |
12
+
13
+ **The overlay always wins.** WS-13 §7: live discovery and upstream extraction never silently
14
+ overwrite `official-doc`/`live-probe` overlay entries, so a conflicting upstream row is dropped in
15
+ the overlay's favour rather than merged field-by-field. Rows are then sorted by `id`/`key` so a
16
+ regeneration is byte-identical.
17
+
18
+ A re-sync **never writes the overlay**. `scripts/provider-source-sync.ts` has no code path that
19
+ opens `overlay/*.json` for writing, it re-reads both files after every run and fails loudly if their
20
+ bytes moved, and `src/extract/pipeline.test.ts` proves an edited overlay survives a re-sync
21
+ byte-for-byte.
22
+
23
+ ## The pin
24
+
25
+ ```json
26
+ { "tag": "v3.8.50",
27
+ "tagObject": "6f5d4e00e817bc01b2ac16fdd66db3840c296416",
28
+ "commit": "5458026c216f77a3da68ea49152dc33470cfe2cb" }
29
+ ```
30
+
31
+ `6f5d4e00…` is the **annotated tag's own object**, not a commit. The OmniRoute report records it as
32
+ "resolving to" v3.8.50, and taking it as the commit would have pinned nothing that a re-tag could
33
+ not move: `git clone --depth 1 --branch v3.8.50` prints *"refs/tags/v3.8.50 6f5d4e00… is not a
34
+ commit!"* and checks out `5458026c…`. Winter records **both** and refuses to run when either fails
35
+ to match (`src/extract/fetch.ts`).
36
+
37
+ `catalogVersion` is therefore `v3.8.50+winter.1` — the upstream release plus the extractor revision,
38
+ the recoverability WS-13 §2 requires. (The spec's illustrative spelling is `3.8.50-winter.1`; the
39
+ composed string is the frozen builder's, `${tag}+${extractorVersion}`, and carries the same two
40
+ facts.)
41
+
42
+ ## What upstream contributed, and what it did not
43
+
44
+ Extraction is a **literal walk**, never an evaluation: `src/extract/literal-extractor.ts` hands file
45
+ text to the TypeScript parser and reads object/array literals. No upstream module is imported,
46
+ loaded, or executed anywhere in this repository, and git itself runs with `core.hooksPath` pointed
47
+ at an empty directory so a hostile repository cannot execute either.
48
+
49
+ 309 upstream files were materialized into a scratch checkout that was deleted before the run ended.
50
+ **Two** of them exist in this repository: `third_party/omniroute-provider-source/LICENSE` and
51
+ `.../NOTICE`, copied verbatim with their blob ids and sha256 recorded in `extraction-manifest.json`.
52
+ No executor, translator, or helper source was copied — the NOTICE register says so, and it is empty
53
+ of code entries on purpose.
54
+
55
+ ### Field-by-field classification
56
+
57
+ Report §6 asks for this table; `src/extract/ledgers.ts`'s `FIELD_PROVENANCE` is its machine-readable
58
+ twin and a test asserts the two agree, so this document cannot drift away from the mapper.
59
+
60
+ | Field | Provenance | Note |
61
+ | --- | --- | --- |
62
+ | `provider.id` | mechanically normalized | upstream id through the allowlist's reviewed `winterId` map — upstream's `gemini` is Winter's `google`; every other id is verbatim |
63
+ | `provider.displayName` | copied verbatim | the product-catalog row's `name` |
64
+ | `provider.protocols` | mechanically normalized | upstream `format` through a closed map; an unknown format **fails the run** |
65
+ | `provider.authKinds` | mechanically normalized | upstream `authType` through a closed map; an unknown auth value **fails the run** |
66
+ | `provider.defaultEndpoints` | mechanically normalized | `api` is upstream's `baseUrl` with exactly the path its own `format` names removed (`/chat/completions`, `/responses`, `/v1/messages`, `/v1beta/models`) — the API **root**, and the exact inverse of what every adapter in that family appends to `connection.baseUrl`. Never a trim to an origin. A `default`-executor row whose URL does not end in its format's suffix, or states none, **fails the run**. `bedrock`/`vertex` are exempt and stay verbatim. `responsesBaseUrl`/`modelsUrl` are copied verbatim. A URL carrying userinfo or a query string is dropped and recorded (R6-11) |
67
+ | `provider.modelDiscovery` | mechanically normalized | derived from `modelsUrl`/`passthroughModels`; upstream has no such field |
68
+ | `provider.liveCatalogAuthority` | mechanically normalized | upstream `liveCatalogAuthoritative` when **stated**; unstated becomes `unknown`, never upstream's `true` default |
69
+ | `provider.adapterId` / `provider.family` | local override | the Winter adapter family the protocol routes to; upstream's `executor` never crosses |
70
+ | `provider.risk` | local override | the reviewed allowlist row |
71
+ | `provider.upstream.{commit,sourcePaths}` | mechanically normalized | the pinned peeled commit and the paths the row was read from |
72
+ | `model.key` / `model.providerId` | mechanically normalized | `<winterId>/<upstream model id>` (WS-13 §8.3) |
73
+ | `model.upstreamId` | copied verbatim, **except** a reviewed correction | verbatim unless `allowlist.json`'s `modelOverrides` names it (today: OpenRouter's `auto` → `openrouter/auto`). Every correction is a `reviewed-normalization` ledger row, and the upstream spelling survives as an alias |
74
+ | `model.displayName` / `model.aliases` | copied verbatim | a duplicate id is dropped **and recorded**, never silently de-duplicated |
75
+ | `model.endpoints` | mechanically normalized | the provider's protocol, or the model's own `targetFormat` when it selects Responses within the same family |
76
+ | `model.contextWindow` / `model.maxInputTokens` / `model.maxOutputTokens` | copied verbatim | `contextLength` (falling back to the provider's `defaultContextLength`), `maxInputTokens`, `maxOutputTokens`; non-positive or non-integer values dropped |
77
+ | `model.inputModalities` | mechanically normalized | `text` plus `image`/`audio`/`video` from `supportsVision`/`supportsAudio`/`supportsVideo` |
78
+ | `model.outputModalities` | local override (a **Winter default**) | upstream declares NO output modality for any model, so `["text"]` is Winter's inference — see "the output-modality stamp" below |
79
+ | `model.toolCalling` / `model.nativeTools` | mechanically normalized | upstream `toolCalling` → `native`/`none`; **absent → `none` at `confidence: "unknown"`** |
80
+ | `model.reasoning.{supported,efforts,continuation}` | mechanically normalized | `supportsReasoning` / `supportedThinkingEfforts` / the provider's `reasoningTransport`; an unstated transport becomes `none` |
81
+ | `model.unsupportedParameters` | copied verbatim | when it is an accepted literal; `Object.freeze([...])` is a call expression and is rejected |
82
+ | `model.status` | local override | `candidate` by default; `experimental` where the allowlist's reviewed `initialModelStatus` says so (R6-16's native cloud, 4 of 53 extracted rows), overridable per row. Never `supported` — upstream presence promotes nothing |
83
+ | `*.pricing` | official-doc derived | **overlay only**, from the vendors' pricing pages with the URL and observation instant |
84
+ | `*.classifierEligible` | live-probe proven | **never set** by extraction or overlay (R6-14) |
85
+ | `reasoning.continuationDomain` / `summaryRequest` / `readableState` / `completionEvent` / `toolLoopRequirement` | official-doc derived | **overlay only**; continuation domain is never inferred from a shared HTTP shape |
86
+
87
+ ### Two tiers of admission citation
88
+
89
+ Every provider row carries `admission.citation` — the document that admits it (WS-13b §1, D21;
90
+ R6b-3 makes a row without one a validation failure, and one citing the audit's `unknown` class a
91
+ refusal). The rows do **not** all rest on the same strength of evidence, and conflating the two
92
+ tiers would be the quiet failure this field exists to prevent, so they are labelled:
93
+
94
+ | Tier | What it is | Which rows |
95
+ | --- | --- | --- |
96
+ | **fetched-document** | a page `docs/research/Provider-third-party-access-audit.md` retrieved and read on 2026-09-06, or a vendor pricing page already reviewed in-repo | the 3 frontier pricing pages, and every P6.5 **overlay** row (the audit's 51 citations) |
97
+ | **pinned-upstream** | the vendor's own site as OmniRoute's product catalog records it at commit `5458026`, blob-pinned in `extraction-manifest.json`, plus the api-key path attested by that id's own pinned `RegistryEntry` (`authType: "apikey"`, its dialect, its base URL) | the 107 P6.5 allowlist admissions, and the 5 P6 rows T1 had left citing `spec:WS-13 §1` |
98
+
99
+ The two tiers above are the two that carry the argument. The full vocabulary has five, and the
100
+ CENSUS below is **generated from the shipped catalog** rather than counted by hand — a hand-counted
101
+ total in a document that describes a data field is a second, unpinned copy of that field, and it is
102
+ the copy a reader trusts.
103
+
104
+ ```sh
105
+ bun run provenance:tiers # rewrite the census block below from the shipped catalog
106
+ bun run provenance:tiers -- --check # exit 1 if the committed block has drifted from the catalog
107
+ ```
108
+
109
+ The `--check` form is already gated: `scripts/provenance-tiers.test.ts` re-renders the census and
110
+ asserts the committed block is byte-identical, and that test runs under the repository's own
111
+ `bun test` in CI. The named script above is for a contributor who has just regenerated the catalog
112
+ and wants to refresh this document by name — not a second gate.
113
+
114
+ <!-- BEGIN GENERATED: admission-tier census (bun run scripts/provenance-tiers.ts) -->
115
+
116
+ Generated from `generated/catalog.json` (`v3.8.50+winter.1`, 165 provider rows). Do not edit by hand.
117
+
118
+ | Tier | Rows | What it means |
119
+ | --- | ---: | --- |
120
+ | **fetched-document** | 39 | a vendor page this repository retrieved and read, on a recorded date |
121
+ | **pinned-upstream** | 101 | the vendor's own site as the pinned upstream product catalog records it, plus that id's own pinned entry — a real, dated reference, but NOT a page read here |
122
+ | **spec-ruling** | 3 | a ruling in an approved spec (or a user ruling recorded in one) admits the PATH; the row's own details are carried from a reviewed ledger entry — `anthropic`, `azure-ai`, `oci` |
123
+ | **local** | 12 | a local installation on the operator's own machine — there is no third party to be admitted by — `docker-model-runner`, `lemonade`, `llama-cpp`, `llamafile`, `lm-studio`, `mlx-gemma`, `mlx-qwen`, `ollama-local`, `oobabooga`, `triton`, `vllm`, `xinference` |
124
+ | **audit** | 10 | the in-repo third-party-access audit's own findings, which cite the documents it read — `aihorde`, `cline`, `clinepass`, `codex-oauth`, `kilocode`, `moonshot`, `opencode`, `openference`, `uncloseai`, `xai-oauth` |
125
+
126
+ **Promotion is two-key** (WS-13b §1, fix-wave R-FW-3): a row leaves `pinned-upstream` only when a fetched vendor document AND a live-gate pass both exist, and no `approved` row or `supported` model may sit on that tier while it does not.
127
+
128
+ <!-- END GENERATED: admission-tier census -->
129
+
130
+ A pinned-upstream citation is a real, dated, verifiable reference — it names a specific blob at a
131
+ verified commit — but it is **not** a page this repository fetched and read, and it is not the
132
+ vendor's terms of service. Its own text says so, in every row. Two consequences are deliberate:
133
+
134
+ * every pinned-upstream row carries `risk.class: "review-required"` with that reason spelled out,
135
+ rather than `approved`. Only `blocked` refuses at resolution (`provider-runtime/src/registry.ts`),
136
+ so the row is usable — but nothing in the catalogue claims a review that did not happen;
137
+ * the audit's own `unknown` disposition is a **different and stronger** statement, and it excludes:
138
+ an id whose decisive document was looked for and not found is in `blocked`, not admitted at this
139
+ tier. `codebuddy-cn` is the worked example.
140
+
141
+ **A citation is checked for LIVENESS, not just for shape (round-1 finding I-3).** Every
142
+ `pinned-upstream` citation host was swept with a HEAD, a GET where the host refuses HEAD, and one
143
+ retry on 5xx. Eight rows cited something that is not a document and were moved to `blocked` with the
144
+ sweep result on the row: three NXDOMAIN (`llamagate`, `monsterapi`, `tokenrouter`), two HTTP 404
145
+ (`sumopod`, `token-kiosk`), one persistent 530 (`x5lab`), one HTTP 200 whose entire body is the
146
+ string "New API" (`chenzk` — a bare gateway shell), and one permanent redirect to a *different
147
+ company's* product page after an acquisition (`predibase` → `rubrik.com`). Two more were repaired
148
+ rather than dropped: `cerebras` cited a page that 301s to a chat product and now cites its Inference
149
+ API docs, and `zai-anthropic` cited the mainland product site although the lane had fetched z.ai's
150
+ own Claude-client doc. A citation is a row's entire evidence, so a citation that resolves to nothing
151
+ is a row with no evidence.
152
+
153
+ **What decision (a) asked for and why it could not be met as written.** The P6.5 plan asked lane X2
154
+ to upgrade five `spec:WS-13 §1` citations (`deepseek`, `openrouter`, `azure-openai`, `bedrock`,
155
+ `vertex`) "to vendor URLs from the audit's citations". The audit's 51 citations cover the OAuth,
156
+ keyless and agent-transport ids it audited; **none of the five appears in it**. They were upgraded to
157
+ the pinned-upstream tier instead, which is a real vendor URL and a strict improvement on a `spec:`
158
+ self-reference, and this paragraph is the record that the stronger upgrade was unavailable rather
159
+ than skipped.
160
+
161
+ ### The two judgement calls worth arguing with
162
+
163
+ **Absent `toolCalling` becomes `none`, not `native`.** Upstream states tool calling on some rows and
164
+ not others, and an unstated capability is unknown. `native` is what makes a model agent-eligible, so
165
+ inferring it from silence would admit every unproven row to Code/Dispatch/Cowork/Build on a guess —
166
+ precisely the silent degradation WS-13 §8.1 prohibits. The cost is that upstream-derived Claude and
167
+ GPT rows report `toolCalling: none` until an overlay row or a live probe corrects them; the
168
+ confidence marker on each says `unknown` so nobody reads it as a denial.
169
+
170
+ **The output-modality stamp says `unknown`, and here is why it cannot say more.** Upstream's
171
+ `RegistryModel` has no output-modality field at all, so `["text"]` on every row is Winter's own
172
+ inference for a chat registry — not something upstream stated. It was shipped as
173
+ `source: "upstream-static", confidence: "inferred"`, which reads as *"upstream said text"*: a false
174
+ claim wearing an upstream label, and precisely the thing that let a text-to-speech model into the
175
+ catalog looking like a text model. Every row carries `confidence: "unknown"` and a `sourceRef`
176
+ that says WINTER DEFAULT in words, and the mapper now also stamps `source: "winter-default"` — the
177
+ member added for exactly this (`src/types.ts`), so a reader filtering evidence BY SOURCE no longer
178
+ gets a Winter guess wearing an upstream label. The prose stays alongside it, because *why* is not
179
+ something an enum can carry.
180
+
181
+ **That wrinkle is now closed, and how it stayed open is worth recording.** The committed
182
+ `generated/upstream-layer.json` is rewritten only by a NETWORK `provider:sync`, which is deliberately
183
+ absent from CI (a maintainer action, not a per-push gate). `--offline` re-merges what is on disk and
184
+ `provider:catalog` never re-extracts, so when the mapper changed to stamp `winter-default` the
185
+ committed layer kept saying `upstream-static`, `catalog.json` inherited it through the merge, and
186
+ **neither CI gate could see the gap** — the transition was pinned by a comment rather than by a
187
+ regeneration. P6.5 lane X2's first network run performed the sync: 99 evidence rows across the two
188
+ generated files moved in one commit, and `provider-source-sync --check` reports byte-identical
189
+ regeneration again. The general lesson is the one this document already makes about counts: a
190
+ generated file that only one un-gated command can write will drift, and the drift will look exactly
191
+ like a comment that is still true.
192
+
193
+ **It came back once, from another lane, and that is the more useful fact.** Lane O branched before
194
+ the fix and authored its two `xai-oauth` model rows on the pre-fix pattern — `upstream-static`, with
195
+ the same now-false justification that `EvidenceSource` has no member for a Winter default. Merging it
196
+ is what surfaced them, because `catalog-integrity.test.ts`'s I3 case asserts the property over the
197
+ MERGED document rather than over one lane's rows. Both were corrected in the merge. The lesson is
198
+ not about `xai-oauth`: a convention repaired in one branch is re-introduced by every branch that
199
+ forked before the repair, so the guard has to live on the merged artifact, and it did.
200
+
201
+ **The overlay's fifteen model rows were carrying the same false label, and a stale reason for it.**
202
+ Each stamped `outputModalities` as `upstream-static` with a `sourceRef` explaining that
203
+ *"`EvidenceSource` is frozen (src/types.ts) with no `winter-derived` member, so the caveat rides the
204
+ confidence marker and this ref"*. That was true when those rows were written and false by the time
205
+ the member was added — the mapper was updated, the fifteen hand-authored rows were not, and their
206
+ own justification went on citing a constraint that no longer existed. All fifteen now stamp
207
+ `winter-default` with a ref that says what is actually true. Nothing in the merged catalog claims
208
+ upstream stated an output modality any more, from either layer.
209
+
210
+ **`unsupportedParameters` fails OPEN, and that direction is deliberate.** `toolCalling` fails CLOSED
211
+ because a wrong `native` admits an unproven model to the agent modes; an empty
212
+ `unsupportedParameters` only means Winter will not *pre-reject* a parameter, and the provider's own
213
+ 400 is the backstop. But an empty list can mean "upstream states none" OR "upstream states some as an
214
+ `Object.freeze([...])` we refuse to evaluate", and the two must not look alike — so every model in
215
+ the second case gets its own `unresolved-reference` ledger row naming the model and the consequence.
216
+ `openai/o3`, `o3-mini` and `o4-mini` are the ones at this pin — **three** rows, matched by the model's
217
+ ARRAY INDEX. The first version of that correlation matched by file, so a single refused field in
218
+ `openai/index.ts` produced a ledger row for all nineteen of its models: sixteen false claims, in a
219
+ ledger that is read as evidence.
220
+
221
+ **`Object.freeze([...])` is rejected like any other call.** "Accept a call when its callee looks
222
+ inert" is a rule that decays the first time upstream renames a helper, and the extractor's one
223
+ guarantee is that it never evaluates anything. The cost is visible and bounded: `o3`, `o3-mini` and
224
+ `o4-mini` lose their upstream `unsupportedParams`, which appear in `generated/rejections.json` under
225
+ `unresolved-reference` for a reviewer to see and the overlay to carry with real evidence.
226
+
227
+ ## Endpoints diverge from upstream on purpose — and the divergence is now the MAPPER's, not the overlay's
228
+
229
+ Upstream's `baseUrl` is the full chat path (`https://api.openai.com/v1/chat/completions`). Winter's
230
+ adapters compose paths themselves, so what a descriptor must carry is the API **root**
231
+ (`https://api.openai.com/v1`). Until P6.5 only the OVERLAY said so: the extractor recorded upstream's
232
+ path verbatim and every upstream row was shadowed by a hand-authored overlay row that quietly
233
+ corrected it. The two layers therefore disagreed about the shape of this one field for the whole of
234
+ P6, and nothing failed — because no unshadowed upstream row had ever reached an adapter.
235
+
236
+ Widening the catalog is precisely what removes those shadows, so the disagreement was about to
237
+ become a hundred-odd rows that 404 at runtime. The mechanism has two links in different packages,
238
+ each reasonable on its own:
239
+
240
+ 1. `runtime/src/provider/session-provider.ts`'s `connectionFrom` copies `defaultEndpoints.api` into
241
+ `connection.baseUrl` **as soon as more than one provider shares an adapter id**;
242
+ 2. the adapter then appends its own protocol path —
243
+ `adapters/openai/chat-completions.ts`: `` `${endpoint.baseUrl}/chat/completions` ``.
244
+
245
+ Measured against a loopback fake: a row carrying the full path reaches
246
+ `/v1/chat/completions/chat/completions`.
247
+
248
+ So the mapper now records the root, by removing **exactly** the path the row's own upstream `format`
249
+ names (`FORMAT_ENDPOINT_SUFFIX` in `src/extract/merge.ts`) — the exact inverse of what the adapter
250
+ appends, and nothing more. A URL is still never trimmed to an ORIGIN; a `default`-executor row whose
251
+ URL does not end in its format's suffix, or states none at all, **fails the run** rather than
252
+ shipping with an absent `api` (an absent one is not inert — `resolveEndpoint` falls back to the
253
+ adapter's own vendor default, so the row would send that provider's credential to another vendor).
254
+ `bedrock`/`vertex` are exempt: single-provider adapters whose `api` is never copied into a
255
+ connection, and whose URLs are region/deployment templates rather than protocol paths.
256
+
257
+ **The evidence that this is the right transformation, rather than a convenient one:** five
258
+ independent human reviews had already performed this exact strip by hand, in `overlay/providers.json`,
259
+ before any of this code existed — and the rule reproduces all five byte-for-byte. `pipeline.test.ts`
260
+ → *"THE PROOF THIS IS THE RIGHT TRANSFORM: it reproduces all five hand-authored overlay endpoints"*
261
+ pins that, and `runtime/src/provider/catalog-endpoint-shape.test.ts` measures the adapter's half
262
+ against a fake rather than describing it.
263
+
264
+ Every strip is a `reviewed-normalization` ledger row naming both strings, so the divergence stays
265
+ visible rather than laundered.
266
+
267
+ ## The denominator (WS-13 §3 step 5)
268
+
269
+ Recomputed at every run into `generated/denominator.json`. At this pin:
270
+
271
+ > Enumerated product-catalog union at the pin: 352 distinct provider ids (per-category sum 352, no
272
+ > id appears in two categories). Backend REGISTRY entries: 270 (222 resolvable by the literal
273
+ > extractor; the remainder are built by a helper CALL, which is never evaluated). 82 catalogued
274
+ > id(s) have no backend registry entry; 0 registry id(s) have no product-catalog row. Upstream's own
275
+ > claims: README.md=352, docs/reference/PROVIDER_REFERENCE.md=352.
276
+
277
+ | Category | Ids | Winter disposition |
278
+ | --- | ---: | --- |
279
+ | noauth | 13 | blocked |
280
+ | oauth | 25 | blocked |
281
+ | web-cookie | 35 | blocked |
282
+ | apikey | 233 | candidate pool — 107 allowlisted, 126 rejected `not-allowlisted`, 0 named individually in `blocked` |
283
+ | local | 14 | Winter-owned (12 chat backends; `comfyui`/`sdwebui` excluded as image systems) |
284
+ | search | 14 | blocked |
285
+ | audio | 12 | blocked |
286
+ | upstream-proxy | 2 | blocked |
287
+ | cloud-agent | 3 | blocked |
288
+ | system | 1 | blocked |
289
+
290
+ **The 351/352 discrepancy does not reproduce at v3.8.50.** The research report recorded it at the
291
+ audited `b7a0c541…` head (declaring 3.8.51); at the pinned tag the enumerated union is 352 and both
292
+ of upstream's own claim sources also say 352. What the pin *does* show is a different and larger
293
+ gap the report did not name: **270 backend registry entries against 352 catalogued providers**, so
294
+ 82 catalogued ids — `azure-openai` among them — have no executable entry at all. A catalog count is
295
+ not a support count, and at this pin it is not even a routable count.
296
+
297
+ The obligation is discharged as a standing computation rather than a restated pair of numbers: if a
298
+ future bump reintroduces a mismatch, `denominator.json` reports it without anyone editing prose.
299
+
300
+ ## Two findings from the pin that a reviewer should see
301
+
302
+ **The retirement migrations are ABSENT here.** The report cites migrations `165`–`168` retiring
303
+ GPL-derived and provenance-hold integrations (Raycast Relay, Hailuo Web, Felo Web, Qwen Web, ChatGPT
304
+ Web). At v3.8.50 the migration series stops at `162`: those retirements landed **after** the pinned
305
+ tag. So the corresponding upstream rows are still live in this snapshot, and the only thing keeping
306
+ them out of Winter is Winter's own allowlist and category dispositions — not upstream's retirement.
307
+ The allowlist keeps the `*retire*`/`*provenance*` path patterns with **zero matches at this pin**
308
+ precisely so a future bump picks them up.
309
+
310
+ **Two verbatim upstream defects were copied as-is and recorded.** Upstream's `openai` entry lists
311
+ `gpt-4o` **twice** (the second occurrence is dropped, class `duplicate-id`), and its
312
+ `claude-sonnet-4.5` row carries the display name *"Claude Sonnet 4.6"*. The name is copied verbatim
313
+ because that is what "copied verbatim" means; correcting it silently would make the catalog
314
+ unfalsifiable against its own source.
315
+
316
+ ## What was excluded, and why
317
+
318
+ `generated/rejections.json` carries all **722** rows. The counts below are generated from the ledger
319
+ and pinned by `catalog-integrity.test.ts` → *"PROVENANCE.md's exclusion table matches the ledger,
320
+ row for row"*, because a hand-typed count is the line that goes stale first and nobody notices.
321
+
322
+ | Class | Rows | What it means |
323
+ | --- | ---: | --- |
324
+ | `not-allowlisted` | 126 | an api-key provider upstream lists that Winter has not curated (WS-13 §1: presence is never inclusion). P6.5 cut this from 225 by admitting 107 through the allowlist and giving **every one of the remaining 126 its own hand-written reason** in `blocked` — a generic class row is not an exclusion anyone can review. That includes the 29 ids whose provider SHIPS as a reviewed overlay row: the class here is still `not-allowlisted` (the mapper stamps it from the id's upstream category, not from why it was kept out), but the REASON on each is the ships-as-an-overlay-row cross-reference `aihorde` and `cline` already carried. **The class name alone never says whether a provider is absent from the catalog** — the reason does |
325
+ | `executable-value` | 116 | functions, arrow functions, `Object.freeze(...)`, `new`, and other calls |
326
+ | `unresolved-reference` | 82 | an identifier whose declaration is outside the allowlist or was itself rejected — including the **three** models whose `unsupportedParams` could not be read (see below) |
327
+ | `dynamic-expression` | 51 | template literals with substitutions, property access, computed keys |
328
+ | `category-web-cookie` | 35 | browser-session transports, excluded categorically |
329
+ | `identity-header` | 30 | vendor client-identity headers — never imported |
330
+ | `category-oauth` | 25 | generic OAuth import is rejected; Winter's OAuth providers are Winter-owned rows |
331
+ | `unsupported-shape` | 47 | opaque runtime config, request defaults, malformed rows, and (P6.5) a `modelsUrl`/`responsesBaseUrl` carrying a query string or userinfo — R6-11 drops it rather than trimming, because a URL minus its query is a different request (`fireworks` is the one at this pin) |
332
+ | `credential-material` | 19 | OAuth client ids/secrets and literal anonymous API keys |
333
+ | `category-local-live-discovery` | 14 | local backends (Winter-owned, live-discovery only) plus the two image systems |
334
+ | `category-search` | 14 | not LLM providers |
335
+ | `category-no-auth` | 13 | reject by default (WS-13 §1) |
336
+ | `category-audio` | 12 | not worker-model providers |
337
+ | `unrepresentable-protocol` | 11 | Vertex's `targetFormat: "claude"` rows — see below |
338
+ | **`reviewed-normalization`** | 114 | **NOT an exclusion.** A row that DID ship, carrying a reviewed, recorded deviation from the pinned tree: the OpenRouter wire id, the Bedrock executor's protocol, the OpenAI and Vertex adapter overrides, the four Vertex partner statuses, and **one endpoint strip per admitted row** (WS-13b §2 — see "Endpoints diverge from upstream on purpose"), which is now the bulk of the class |
339
+ | `url-builder` | 4 | executable URL builders (WS-13 §13's security floor names this exactly) |
340
+ | `category-cloud-agent` | 3 | remote agent products |
341
+ | `category-upstream-proxy` | 2 | no proxy-of-proxy layer |
342
+ | `category-system` | 1 | `auto` is routing policy, which this layer bans |
343
+ | `duplicate-id` | 1 | upstream's second `gpt-4o` |
344
+ | `no-registry-entry` | 1 | `azure-openai` — catalogued upstream, with no backend entry |
345
+ | **`out-of-scope`** | 1 | **`gemini-3.1-flash-tts-preview`** — a TEXT-TO-SPEECH model. WS-13 §4 is a MUST: `tts` rows never feed the worker-model picker, and `scope` is per PROVIDER, so a `gemini` row cannot declare itself `tts` while its provider is `llm`. Excluded through the allowlist's reviewed `modelOverrides`, never a name heuristic — a heuristic would silently drop a future model whose id happened to match |
346
+
347
+ **`unrepresentable-protocol` is the interesting one.** Upstream's `vertex` entry lists eleven
348
+ `claude-*` models with `targetFormat: "claude"` — Claude models served over Vertex's endpoint in the
349
+ Anthropic dialect. `WinterProviderDescriptor.protocols` is per-**provider**, and `endpoints`
350
+ distinguishes only chat from responses within one family, so nothing in the schema can say "this
351
+ model speaks a different dialect from its provider". Emitting those rows anyway would resolve them
352
+ onto `winter.google-generate-content` and serialize the wrong dialect on the wire, so they are
353
+ refused with the reason recorded. Anthropic-on-Vertex is a recorded carry (R6-16), and a Winter row
354
+ for it belongs under a provider whose protocol matches.
355
+
356
+ ## Adapter ids are read from the lanes, not guessed
357
+
358
+ Every cohort provider row's `adapterId` is the id constant the adapter that serves it actually
359
+ exports, read from the lane branches (`git show p6/lane-a:…/adapters/openai/*.ts`,
360
+ `git show p6/lane-b:…/adapters/google/*.ts`). Two were wrong, and a registry resolves an adapter **by
361
+ that id**, so both were live misroutes rather than cosmetic drift:
362
+
363
+ - **`vertex` named `winter.google-generate-content`** — the plain Gemini adapter. Lane B's Vertex
364
+ adapter is `winter.vertex-gemini` (`VERTEX_ADAPTER_ID`). A Vertex session would have been served by
365
+ the Gemini API adapter: no location-scoped URL, no ADC credential, and
366
+ `generativelanguage.googleapis.com` on the wire. `family` stays `google` deliberately — Vertex is a
367
+ *transport* over the same GenerateContent mapping (ruling R6-A), not a second dialect.
368
+ - **The twelve local rows named `winter.openai-chat-completions`.** They now name
369
+ `winter.local-openai`, the default `createLocalOpenAIAdapter` exports. The old value forced a host
370
+ to register the local adapter *under the chat adapter's id*, which **shadowed** the real Chat
371
+ Completions adapter for `openai`, `openrouter` and `deepseek`. Lane A can drop the `id:` override
372
+ from its wiring line.
373
+
374
+ - **`openai` derived `winter.openai-chat-completions`** in the upstream layer, from upstream's
375
+ provider-level `format: "openai"` — which names OmniRoute's own default execution path, not the
376
+ surface Winter drives. Six upstream openai rows carry `targetFormat: "openai-responses"` (the
377
+ `*-pro` and GPT-5.6 families are responses-ONLY), so the layer held responses-only models under a
378
+ Chat Completions adapter. The overlay row has said `winter.openai-responses` since the seed, and
379
+ that is precisely what hid it — the merged catalog was consistent while the layer was not. Found
380
+ by running the cross-layer gate over the STANDALONE layer, the same shadow class as Vertex.
381
+
382
+ `azure-openai` and `vertex` model rows are `experimental` per R6-16 (native cloud enters as
383
+ experimental, and both adapters are live). `bedrock` rows stay `candidate`: Lane N has not landed an
384
+ adapter, and R6-16's own demotion criterion covers that case. Nothing anywhere is `supported` —
385
+ that requires the behavioural corpus (WS-13 §13).
386
+
387
+ ## Reviewed model-id corrections
388
+
389
+ `allowlist.json`'s `modelIdCorrections` is a hand-maintained map from an upstream model id to the id
390
+ the provider documents on its own wire. One entry exists: OpenRouter's auto-router, which upstream
391
+ lists as a bare `{ id: "auto" }` while OpenRouter documents `openrouter/auto`. Upstream's spelling
392
+ put `auto` on the wire from **both** resolution paths — step 1 (the seeded row's own `upstreamId`)
393
+ and the `allowUnlisted` pass-through, which strips a self-prefix — so correcting it at the source
394
+ fixes both. The upstream spelling survives as an **alias**, so a caller writing bare `auto` still
395
+ reaches the corrected wire id, and no row remains that would send a bare `auto`.
396
+
397
+ Every correction is written into `generated/rejections.json` with its reason. It is a *mechanical
398
+ normalization*, never a silent edit: a reader can diff the catalog against the pinned source and
399
+ find the one place they differ, with the justification attached.
400
+
401
+ ## Why `google/gemini-2.5-pro` ships `efforts: []`
402
+
403
+ Not an omission. Gemini's `generateContent` surface has **no effort vocabulary at all** — thinking is
404
+ *budgeted* (`thinkingConfig.thinkingBudget`), not tiered into named levels. WS-13 §8.2 forbids
405
+ treating vocabularies as interchangeable, so borrowing OpenAI's `low`/`medium`/`high` here would
406
+ invent a control the endpoint does not accept, and the adapter would (correctly) reject the selection
407
+ before sending. The row **does** carry `summaryRequest`
408
+ (`thinkingConfig.includeThoughts`, `official-doc`, continuity report §6.1), so summaries are
409
+ requestable from session start per WS-13 §8.2's proactive-summaries rule; it is only the *effort
410
+ tiers* that do not exist. The overlay states this in a `$comment` on the `reasoning` block, but
411
+ `scripts/provider-catalog.ts` strips `$comment` keys from the merged catalog — which is why the
412
+ reason lives here, where a reader of the shipped artifact can find it.
413
+
414
+ ## Blocked stays blocked
415
+
416
+ A provider reaches the catalog only through a reviewed edit to
417
+ `third_party/omniroute-provider-source/allowlist.json`. The check runs **both ways**: an id the
418
+ allowlist admits whose upstream category is not the api-key candidate pool **fails the whole run**
419
+ rather than being imported (WS-13 §3 step 7's blocked class transition), and so does a category
420
+ change on an already-admitted id. That is what makes "blocked → supported requires an explicit
421
+ reviewed allowlist change" a property of the pipeline instead of a promise.
422
+
423
+ ## Pricing
424
+
425
+ `overlay/models.json` carries list prices for the cohort rows below, each with the vendor's own
426
+ pricing page as `sourceRef` and the observation instant. The set is pinned by name in
427
+ `src/extract/catalog-integrity.test.ts`, so a row gaining or losing a price is a deliberate edit:
428
+
429
+ | Model | Input | Output | Cache read | Cache write | Source |
430
+ | --- | ---: | ---: | ---: | ---: | --- |
431
+ | `anthropic/claude-fable-5-1` | 10 | 50 | 0.25 | 12.50 | docs.anthropic.com/en/docs/about-claude/pricing |
432
+ | `anthropic/claude-haiku-4-5-20251001` | 1.00 | 5.00 | 0.10 | 1.25 | claude.com/pricing |
433
+ | `anthropic/claude-opus-5` | 5.00 | 25.00 | 0.50 | 6.25 | claude.com/pricing |
434
+ | `anthropic/claude-sonnet-5` | 2.00 | 10.00 | 0.20 | 2.50 | claude.com/pricing |
435
+ | `google/gemini-2.5-pro` | 1.25 | 10.00 | 0.125 | — | ai.google.dev/gemini-api/docs/pricing |
436
+ | `google/gemini-3.5-flash-lite` | 0.30 | 2.50 | 0.03 | — | ai.google.dev/gemini-api/docs/pricing |
437
+ | `google/gemini-3.8-flash` | 0.75 | 3.75 | 0.075 | — | ai.google.dev/gemini-api/docs/pricing |
438
+ | `openai/gpt-4.1` | 2.00 | 8.00 | 0.50 | — | developers.openai.com/api/docs/pricing |
439
+ | `openai/gpt-5.6-luna` | 0.20 | 1.20 | 0.02 | — | developers.openai.com/api/docs/pricing |
440
+ | `openai/gpt-5.6-sol` | 4.00 | 20.00 | 0.40 | — | developers.openai.com/api/docs/pricing |
441
+ | `openai/gpt-5.6-terra` | 2.00 | 12.00 | 0.20 | — | developers.openai.com/api/docs/pricing |
442
+ | `openai/gpt-6-astra` | 10.00 | 50.00 | 1.00 | 12.50 | developers.openai.com/api/docs/pricing |
443
+ | `openai/o4-mini` | 1.10 | 4.40 | 0.275 | — | developers.openai.com/api/docs/pricing |
444
+ | `xai/grok-4.6` | 2.00 | 6.00 | 0.50 | — | docs.x.ai/docs/models |
445
+
446
+ USD per million tokens. This closes the seed's disclosed gap: `estimateCostUsd` returns
447
+ `costBasis: "list"` for these rows, and `maxBudgetUsd` is live for them.
448
+
449
+ **`ModelPricing` holds one rate per direction, and four disclosed limits follow from that.**
450
+
451
+ 1. **Gemini 2.5 Pro is tiered by PROMPT SIZE** — prompts over 200k tokens bill at $2.50/$15.00/$0.25
452
+ — so the standard tier is recorded and cost is **under-reported for prompts above 200k**.
453
+ 2. **Gemini 3.8 Flash is tiered in TIME** (P7a). Its page states $0.75/$3.75/$0.075 "through
454
+ December 31, 2026" and exactly double from January 1, 2027. The current rate is recorded, so from
455
+ that date the row under-reports by 2x until it is re-fetched. Its `-lite` sibling carries no such
456
+ schedule, and its row says so — the disclosure is each row's own evidence, never boilerplate.
457
+ 3. **Claude Fable 5.1 has two cache-write durations** (P7a): $12.50/MTok for the 5-minute write and
458
+ $20/MTok for the 1-hour one. The 5m rate is recorded, matching every other Anthropic row here;
459
+ the 1h rate has no field. Its cache READ is the vendor's documented 0.025x exception ($0.25/MTok,
460
+ not the usual 0.1x), which is why that number looks out of line with its siblings.
461
+ 4. **Batch (-50%), fast-mode, Flex/Priority and data-residency (1.1x) modifiers are never folded in.**
462
+ They are separate rate cards, and a blended number would be a price no invoice ever shows.
463
+
464
+ Every one of these is stated in the offending row's own `sourceRef`, so the caveat travels with the
465
+ data rather than living only here. **Gateway (`openrouter/*`), Azure-deployment, Bedrock and Vertex
466
+ rows are deliberately unpriced**: their prices are the reseller's, not the vendor list, and
467
+ attributing a vendor price to them would put a number on the wrong billing boundary — the exact
468
+ confusion WS-13 §8.3 keeps `openai/gpt-x` and `gateway/gpt-x` apart to avoid. Every upstream-derived
469
+ row is unpriced too, by construction: the extractor cannot emit `pricing` at all.
470
+
471
+ ## `classifierEligible` is still absent everywhere
472
+
473
+ R6-14 sets it only after the safety corpus passes live. The catalog's answer to "may this model
474
+ serve as the permission classifier?" therefore remains **no** — Manual fallback, the fail-safe
475
+ direction, never a silent weakening.
476
+
477
+ ## The helper-built entries: probed, then admitted or refused individually (round-1 finding I-1)
478
+
479
+ 74 upstream `apikey` ids are built by a **helper call** (`buildOpenAiCompatibleRegistryEntry(...)`)
480
+ or a shared constant, which the literal extractor refuses to evaluate — so the pinned tree yields no
481
+ endpoint, auth, executor or model list for them, only the product-catalog identity. Round 1 ruled
482
+ that carrying all 74 on one generic reason was not good enough: an id is admitted from the **vendor's
483
+ own documentation** where one exists, and refused **individually, with the probe result**, where it
484
+ does not.
485
+
486
+ | outcome | n | what it means |
487
+ | --- | ---: | --- |
488
+ | **admitted** as reviewed overlay rows | **29** | a vendor documentation page was fetched and read on 2026-09-06 **and** it names a fixed API root |
489
+ | **admitted** as reviewed **per-tenant** rows (P7a) | **2** | `azure-ai`, `oci`: the endpoint is a per-tenant template, so the row ships **none** and the host supplies one (below) |
490
+ | refused — out of **scope** | 14 | image, video, embedding, reranking or web-extraction services. Not held pending a document: more evidence would not admit them |
491
+ | refused — **docs reached, no fixed endpoint** | 20 | a docs page answered 200 but states no base URL, or the host answered 403/530, or the vendor documents two hosts and no single base |
492
+ | refused — **no public fixed host at all** (enterprise) | 9 | the inference host is per-deployment or per-tenant by design, or no docs page could be reached |
493
+
494
+ For the 14 admitted from the probe list, **two independent sources agree on the base**: the vendor's
495
+ own documentation page, and the base upstream's product catalog states in its `apiHint` at the pin.
496
+ Neither was taken on the other's word, and no row was authored from the pinned tree alone —
497
+ hand-transcribing a helper call's arguments is precisely the extraction the literal extractor
498
+ refuses, so it is not a substitute for the document.
499
+
500
+ **Three traps, recorded because each looks like an admission until it is read.** `openference-api` is
501
+ not a provider: its documentation *is* the shipped `openference` row's documentation, on the same
502
+ base — one vendor wearing two ids. `hcnsec` and `helixmind` declare `format: "claude"` with a
503
+ `/v1/chat/completions` base, so the entry contradicts its own dialect and neither value can be
504
+ trusted; the vendor's doc is the tie-breaker and neither has a readable one. `muse-code` has no
505
+ vendor doc at all — its recorded `website` is a GitHub repository URL.
506
+
507
+ ## The per-tenant rows: a row that ships NO endpoint (P7a, WS-13b §2/§10)
508
+
509
+ `azure-ai` and `oci` have real documented public APIs and were refused through P6.5 for one reason:
510
+ `defaultEndpoints.api` is immutable generated data (R6-11) and a per-tenant template
511
+ (`https://<resource>.services.ai.azure.com/openai/v1`,
512
+ `https://inference.generativeai.<region>.oci.oraclecloud.com/openai/v1`) is not an endpoint. The
513
+ round-1 ruling deferred them to "a dedicated host-supplied-endpoint adapter shape". **The user's
514
+ ruling of 2026-09-06 replaced that with a user-entered endpoint field**, and P7a ships it:
515
+
516
+ | field | what it means |
517
+ | --- | --- |
518
+ | `requiresUserEndpoint: true` | the row ships **no** `api` endpoint at all. The validator refuses one — presence, not shape: a plausible placeholder parses, validates, and would be copied into a connection profile and called |
519
+ | `endpointTemplate` | the documented shape, e.g. `https://<resource>.services.ai.azure.com/openai/v1`. **Never sent, never parsed as a URL** — it is documentation, and the only thing the runtime's typed `endpoint-required` refusal has to show a user |
520
+
521
+ At runtime the host's `connection.baseUrl` is **required** and is evaluated as a **USER** endpoint
522
+ (`endpointOrigin: "user"`), so no privileged header ever rides it (WS-13 §5 / R6-L). Absent, the
523
+ session refuses before a request exists rather than falling back to the shared adapter's vendor
524
+ default — which, for a row on `winter.openai-chat-completions`, would have meant this provider's
525
+ credential on the wire to `api.openai.com`.
526
+
527
+ **Neither row is authored from a fetched page.** Both cite `tier: "spec-ruling"` — the user ruling
528
+ admits the *path*, and the template is transcribed verbatim from the id's own P6.5 ledger entry.
529
+ This repository has not read `learn.microsoft.com/azure/ai-foundry` or
530
+ `oracle.com/artificial-intelligence/generative-ai` for content. Promotion is two-key as everywhere
531
+ else: the fetched page (upgrading the citation to `fetched-document`) **and** a live-gate pass
532
+ against a real tenant.
533
+
534
+ **No model rows, and `modelDiscovery: "none"`.** A per-tenant surface serves whatever deployments the
535
+ operator created; no document read here enumerates them, and seeding rows would be a claim about
536
+ somebody else's tenant. A host reaches models with `allowUnlisted` — both rows are
537
+ `liveCatalogAuthority: "unknown"`, which is the door R6-F opens.
538
+
539
+ ## Dialect siblings, and the two keyless rows (P6.5, R6b-5 / WS-13b §8.4)
540
+
541
+ A provider row carries exactly one `adapterId`, so a vendor that documents **two wire dialects at two
542
+ base URLs** is **two rows** — each with its own endpoint, its own model keys and the dialect in its
543
+ `displayName`. Four pairs ship:
544
+
545
+ | OpenAI dialect | Anthropic dialect | Anthropic base URL | Where the pair comes from |
546
+ | --- | --- | --- | --- |
547
+ | `deepseek` (extracted) | `deepseek-anthropic` (overlay) | `https://api.deepseek.com/anthropic` | vendor guide, retrieved 2026-09-06 |
548
+ | `zai` (overlay) | `zai-anthropic` (**extracted**) | `https://api.z.ai/api/anthropic` | vendor docs for both halves, retrieved 2026-09-06 |
549
+ | `moonshot` (overlay, token) | `kimi-coding` (overlay, **subscription**) | `https://api.kimi.com/coding` | Kimi Code docs, retrieved 2026-09-06 |
550
+ | `minimax` (extracted) | `minimax-anthropic` (overlay) | `https://api.minimax.io/anthropic` | vendor Anthropic-SDK reference, retrieved 2026-09-06 |
551
+
552
+ The `zai` pair is the one worth reading twice: **upstream's own `zai` entry is the ANTHROPIC one**
553
+ (`format: "claude"` at `api.z.ai/api/anthropic/v1/messages`, which z.ai's Claude-client doc confirms
554
+ verbatim), so the allowlist admits that id under the `zai-anthropic` **`winterId`** — the same rename
555
+ door `gemini` → `google` already uses — and the OpenAI half is the reviewed overlay row. It is also
556
+ why `displayNameOverride` exists: upstream's product catalog has one name per vendor ("Z.AI"), and two
557
+ rows reading "Z.AI" are two rows a user cannot choose between. The override is a reviewed allowlist
558
+ edit, recorded in the ledger like every other normalization, and it is not a licence to rename
559
+ providers for taste.
560
+
561
+ **The Anthropic adapter is multi-provider in fact, and that was measured rather than assumed.**
562
+ `runtime/src/provider/catalog-endpoint-shape.test.ts` drives two sibling rows through the real
563
+ catalog-resolved adapter against a loopback fake; each reaches its **own** `<root>/v1/messages`.
564
+ There is no `providerId === "anthropic"` guard anywhere in `adapters/anthropic/messages.ts`.
565
+
566
+ **Subscription rows are a billing fact, not a label.** `kimi-coding` and `clinepass` carry
567
+ `pricingBasis: "subscription"`, so `priceUsage` returns nothing for them and a session on either
568
+ reports no `total_cost_usd`. `clinepass` shares an endpoint **and a key** with the token-priced
569
+ `cline` row and is still a separate row, because the basis is per row and that is the whole mechanism.
570
+
571
+ **The two keyless rows carry no credential, and `aihorde` is the reason to say so explicitly.**
572
+ AI Horde documents an anonymous default key. **It is not in this repository** — not in the catalog,
573
+ not in an adapter, not in a fixture. The row records only that a documented anonymous default
574
+ *exists* and cites the page that names it; supplying it (or, better, a registered key, which buys
575
+ queue priority) is the host's act through the ordinary credential path, which is why its `authKinds`
576
+ is `api-key` rather than a keyless kind. `catalog-integrity.test.ts` asserts the literal is absent by
577
+ name. `uncloseai` needs no credential at all and carries `authKinds: ["custom"]`: `local-none` is
578
+ reserved for a **local installation** (the twelve WS-13 §12 rows) and would make `connectionFrom`
579
+ stamp `local: true` on a public https host.
580
+
581
+ ## Winter-owned rows
582
+
583
+ `codex-oauth` (D11) and the twelve local OpenAI-compatible ids (WS-13 §12) carry
584
+ `upstream.project: "winter"` and an **empty** commit: no extraction produced them, and the emptiness
585
+ is the honest marker. Local inventories are machine-specific, so `modelDiscovery: "local"` and
586
+ `liveCatalogAuthority: "unknown"` on every one, and live discovery is the real catalog.
587
+ `azure-openai` is the in-between case: it *is* catalogued upstream, so it keeps
588
+ `project: "OmniRoute"` and the real commit, but its only `sourcePath` is a product-catalog file and
589
+ every endpoint and auth fact on it is Winter-authored.
590
+
591
+ ## Secrets
592
+
593
+ `validateCatalog` fails the build on any credential-shaped field name or value anywhere in the
594
+ document (`scanForSecrets`), and the extractor runs the **same** scan over the raw upstream literals
595
+ *before* mapping — a credential that never enters the extraction cannot leak from it, including into
596
+ a debug dump no output-side scan would ever see. Field names carrying credential or identity
597
+ material (`oauth`, `anonymousApiKey`, `headers`, `extraHeaders`, `defaultHeaders`) are rejected on
598
+ sight, whatever their shape. WS-13 §6 is categorical: descriptors never contain secrets.