Audit: {provider, model} vs {providerId, model} Schema Fitness

Date: 2026-05-11 Branch: cvl-node-tree-direct Scope: All TypeScript files in the monorepo (excluding node_modules, .d.ts)


Background

Two distinct object shapes coexist in this codebase:

Shape Type name Where it lives Fields
ChainSlot ChainSlot from @cv/schemas/cloud-keys.schema Stored in CloudKeys.chains, job.chain_overrides, BYOK editor { provider: string, model: string }
ProviderSlot ProviderSlot from @cv/domain/ai Returned by resolveChain() / chainResolver() — the ready-to-call shape with injected API key { providerId: string, apiKey: string, model: string }

The resolveChain() function converts ChainSlot → ProviderSlot by reading config.providerKeys[slot.provider] and renaming the field:

result.push({ providerId: slot.provider, apiKey: key, model: slot.model });

So providerId is correct on resolved/runtime objects; provider is correct on stored/seed objects. Confusion arises where test fixtures construct objects that are meant to be stored ChainSlots but mistakenly use providerId.


Findings Table

File:line What we found Classification Fix needed?
lib/schemas/src/cloud-keys.schema.ts:18-21 ChainSlotSchema = z.object({ provider, model }) — canonical definition REFERENCE No
lib/domain/src/ai.ts:45-49 ProviderSlot = { providerId, apiKey, model } — canonical resolved shape REFERENCE No
lib/domain/src/chain.ts:238 resolveChain maps slot.provider → providerId — correct conversion LEGITIMATE No
lib/domain/src/chain.ts:193,350,379 pruneOrphanedChainSlots and resolveKeyFromChain use slot.provider — correct LEGITIMATE No
lib/domain/src/pipeline/stages/enrich.ts:235,288 slot.providerId === 'jina'slot here is from chainResolver(), returns ProviderSlot[] LEGITIMATE No
lib/domain/src/pipeline/stages/enrich.ts:285 hasResearchChainSlot type annotation: { providerId: string }[] — matches ProviderSlot, correct LEGITIMATE No
web/src/routes/api/workspaces/[id]/process-requests/fallback/+server.ts:757 researchChain.some((slot) => slot.providerId === 'jina')researchChain is ProviderSlot[] from resolveChain() LEGITIMATE No
web/src/routes/api/llm/evaluate/+server.ts:240 slot.providerId — slot is from ProviderSlot[] (mergeProviderChains input) LEGITIMATE No
web/src/routes/api/chain/server.test.ts:180,198,216 expect(resolveChain(...)).toEqual([{ providerId, apiKey, model }])resolveChain returns ProviderSlot[] so providerId is correct LEGITIMATE No
lib/domain/tests/seed.test.ts:265 { provider: 'custom', model: 'special' } — correct ChainSlot shape in l2 override LEGITIMATE No
lib/domain/tests/seed.test.ts:27,36,47,57 ChainSlot[][] = [[{ provider: ..., model: ... }]] — all correct LEGITIMATE No
lib/domain/tests/pipeline-stages.test.ts:95-99 makeChainResolver maps s.provider → providerId — correct ChainSlot→ProviderSlot conversion LEGITIMATE No
lib/domain/tests/bundle-equivalence.test.ts:469,491,563,647 chainResolver: (() => ([{ providerId, apiKey, model }])) — these stub a chainResolver which by contract returns ProviderSlot[] LEGITIMATE No
lib/domain/tests/bundle-equivalence.test.ts:467,558,644 chains: { extract: { chat: [{ provider, model }] } } in chainConfig — correct ChainSlot shape LEGITIMATE No
web/src/routes/api/workspaces/[id]/process-requests/fallback/server.test.ts:989-992 chain_overrides: JSON.stringify({ evaluate: [{ provider: 'mistral', model: 'mistral-small-latest' }] })CORRECT, already fixed (comment at line 989 documents it) LEGITIMATE No — already correct
web/src/routes/api/workspaces/[id]/process-requests/fallback/server.test.ts:1054-1055 as Array<{ providerId: string; model: string }> cast on a ProviderSlot[] — this is reading from callWithChain's first arg, which is ProviderSlot[]. The cast correctly reflects ProviderSlot (sans apiKey which is not needed for the assertion) LEGITIMATE No
web/src/routes/api/workspaces/[id]/process-requests/fallback/server.test.ts:912-913 expect.objectContaining({ providerId: 'mistral' }) on ai-usage request body — that endpoint receives providerId (telemetry field), not a ChainSlot LEGITIMATE No
mcp/test/tools.test.ts:278 chain_overrides: JSON.stringify({ evaluate: [{ providerId: 'mistral', model: 'mistral-small-latest' }] }) — seeds a chain_overrides value that would be parsed as a ChainSlot[] by flattenCascade. providerId should be provider. WRONG SHAPE Yes — test fixture bug
mcp/test/tools.test.ts:289 evaluate: [{ providerId: 'mistral', model: 'mistral-small-latest' }] in the matching expect — correct expectation shape for the wrongly-seeded fixture (both seed and assertion are wrong together, but the test passes because list_jobs round-trips the raw JSON without schema-validating slot contents) WRONG SHAPE Yes — fix with line 278
Production code: all fallback/+server.ts usages of providerId All reference telemetry fields on stage results (not ChainSlot objects) — cvUsageInfo.providerId, clUsageInfo.providerId, stage.telemetry.providerId LEGITIMATE No
All pipeline.svelte.ts usages of providerId All reference attempt/telemetry/attribution objects (attempt.providerId, telemetry.providerId) — ProviderSlot or telemetry, never a stored ChainSlot LEGITIMATE No
lib/domain/src/ai.ts (all ~30 instances) All on ProviderSlot objects in callWithChain internals LEGITIMATE No
lib/domain/src/scrape.ts (all instances) All on ProviderSlot objects passed to scrape() LEGITIMATE No
lib/domain/src/tailor/index.ts (all instances) Telemetry/usage objects from callWithChain returns LEGITIMATE No
web/src/routes/api/ai-usage/+server.ts API endpoint that receives providerId in telemetry body — that's the field name in the POST body, not a ChainSlot LEGITIMATE No
web/src/routes/api/evaluate/+server.ts, extract-fields/+server.ts Same as above — telemetry providerId from stage result LEGITIMATE No

Inverse Mistake Check

Checking for places that use {provider, model} where the consumer expects {providerId, model}:

  • lib/domain/tests/bundle-equivalence.test.ts: chainConfig.chains entries correctly use {provider, model} (ChainSlot); chainResolver stubs correctly return {providerId, apiKey, model} (ProviderSlot). No confusion.
  • lib/domain/tests/pipeline-stages.test.ts: makeChainResolver correctly converts s.provider → { providerId: s.provider, apiKey: ..., model: s.model }. No confusion.
  • No inverse mistake instances found.

Wrong-Shape Instances — Proposed Fixes

Instance 1: mcp/test/tools.test.ts:278-289

What: Test fixture seeds chain_overrides for a job with { providerId: 'mistral', model: 'mistral-small-latest' } inside evaluate: [...]. Since chain_overrides is Record<capability, ChainSlot[]> per lib/domain/src/types.ts:60, the slot shape must be { provider: string, model: string }.

Why the test passes today: list_jobs in the MCP tool round-trips chain_overrides by parsing it from JSON and returning it to the caller as a plain object without schema-validating the slot contents. The expect(jobs[0].chain_overrides).toEqual({ evaluate: [{ providerId: ... }] }) assertion then matches what was seeded. The test never exercises the slot through flattenCascade or resolveChain, so the wrong field name never causes a failure — but the fixture is still wrong and would produce incorrect runtime behaviour (the slot would be filtered out by pruneOrphanedChainSlots because slot.provider would be undefined).

Proposed fix:

- chain_overrides: JSON.stringify({ evaluate: [{ providerId: 'mistral', model: 'mistral-small-latest' }] }),
+ chain_overrides: JSON.stringify({ evaluate: [{ provider: 'mistral', model: 'mistral-small-latest' }] }),
- expect(jobs[0].chain_overrides).toEqual({
-     evaluate: [{ providerId: 'mistral', model: 'mistral-small-latest' }]
- });
+ expect(jobs[0].chain_overrides).toEqual({
+     evaluate: [{ provider: 'mistral', model: 'mistral-small-latest' }]
+ });

Summary Verdict

Metric Count
Total providerId references in TS files 386
LEGITIMATE — on ProviderSlot objects or telemetry 384
WRONG SHAPE — ChainSlot with providerId instead of provider 2 (lines 278, 289 in mcp/test/tools.test.ts)
AMBIGUOUS 0
Tests silently broken by wrong shape 1 test (list_jobs parses JSON text blobs before validating job shape) — passes but exercises wrong fixture data
Production code bugs 0 — no production code constructs a ChainSlot with providerId

How many tests are silently broken?

One test in mcp/test/tools.test.ts (the list_jobs parses JSON text blobs test at line 272) seeds a wrong-shape fixture and asserts the round-tripped wrong shape. It passes because list_jobs never schema-validates ChainSlot contents. If the tool were updated to validate or forward the chain_overrides through flattenCascade, the slot would be silently dropped (pruned as orphaned — no slot.provider key).

The previously-reported bug in fallback/server.test.ts is already fixed: the chain_overrides fixture at line 992 now correctly uses { provider: 'mistral', model: 'mistral-small-latest' } (with an explanatory comment at line 989).

  1. Fix mcp/test/tools.test.ts lines 278 and 289 (one-line patch each, shown above).
  2. No production code changes needed.
  3. Consider adding a ChainSlotSchema.parse() or z.array(ChainSlotSchema) validation at the list_jobs deserialization boundary in the MCP tool to catch future shape drift at the ingestion point.
Source: wiki/content/investigations/audit-provider-vs-providerId-2026-05-11.md