# Provenance

Every field on every row says whether the source published it, whether we inferred it, or whether it is absent. This is the most distinctive thing in the product.

**What this covers:** field_sources — per-field provenance; require_fields — the honest subset; Why require_fields=category is an error; Salary, specifically.

**Assumed knowledge:** That you have seen a job row. If not, read the [quickstart](./quickstart.md) first.

**Canonical HTML:** https://jobopportunitiesapi.org/docs/ledger/provenance  
**Machine-readable index:** https://jobopportunitiesapi.org/docs/ai/index.md  
**Last verified:** 2026-08-22  
**Superseded by:** the live API at https://api.jobopportunitiesapi.org and its spec at https://jobopportunitiesapi.org/openapi.json — where this file and the API disagree, the API is right.

---

Most data products give you a field and leave you to guess where it came from. A `remote: true` might be a checkbox the employer ticked or a model's reading of the phrase “flexible working”. Those are different facts, and merging them is how a dataset becomes unusable for anything that matters.

---

<a id="field-sources"></a>

## 1. field_sources — per-field provenance

Every job row carries a field_sources object marking each field published (the source said so), inferred (we derived it) or absent (there is none).

### 1.1 In depth

Three values, and the third is as important as the other two. `absent` is not the same as `false` and it is not the same as a guess: it says the source carried nothing here, so anything you assert about this field is yours, not ours.

- **`published`** — The value was carried by the source. It is a quotation.
- **`inferred`** — **We** produced it. It may well be right; it is not a quotation, and you should treat it as our reading rather than the employer's statement.
- **`absent`** — There is no value. A different failure from a guess, and reported as such.

Two fields are **always** `inferred`, by construction: `category` and `seniority` are read off the job title by a classifier and are never a field an employer filled in. `category` additionally carries `category_confidence`, and no category is published at all below 0.6 — so that number is either null or at least 0.6.

One field is subtler. `salary` is `published` when the source stated it in a structured field, and `inferred` when a real figure was quoted in the advert text and we read it out and normalised it — we chose the number and the period, so it is our reading. It is **never** an estimate: modelled salaries are refused by the projection itself and cannot reach this API by any route.

### 1.2 Exact contract

Per-field provenance. `published` means the value was carried by the
source. `inferred` means WE produced it — it may be right, it is not a
quotation. `absent` means no value, which is a different failure from a
guess and is reported as such.

`category` and `seniority` are ALWAYS `inferred`: both are read off the
job title by a classifier, never from a field the employer filled in.

`salary` is `published` when the source stated it in a field
(`salary_source: structured`), and `inferred` when we read it out of the
advert text (`salary_source: parsed_description`). It is never an
estimate: the projection refuses to emit Erioun's AI salary predictions
at all, so a modelled figure cannot reach this API by any route.

| field | type | always present | allowed values | meaning |
| --- | --- | --- | --- | --- |
| category | string | yes | published, inferred, absent | — |
| description | string | yes | published, inferred, absent | — |
| employment_type | string | yes | published, inferred, absent | — |
| location | string | yes | published, inferred, absent | — |
| posted_at | string | yes | published, inferred, absent | — |
| remote | string | yes | published, inferred, absent | — |
| salary | string | yes | published, inferred, absent | — |
| seniority | string | yes | published, inferred, absent | — |
| source_type | string | yes | published, inferred, absent | `inferred` when the value is the provider's default class; `published` when the row itself was classified upstream. TODAY THIS IS ALWAYS `inferred`: the row-level column is null on every live listing, so the provider's class is the only path taken. `published` is reserved for when per-row classification lands. Do not build a filter that expects it to appear. |

> **source_type is always `inferred` today** — The per-row classification column exists in the schema and is null on every live row, so the provider's class is the only path taken. `published` is reserved for when per-row classification lands. Do not build a filter that expects it to appear.

### 1.3 Worked examples

One row, with its provenance block

```console
$ curl -s 'https://api.jobopportunitiesapi.org/public/jobs?limit=1' \
  | jq '.data[0] | {title, company, remote, remote_inferred, category, category_confidence, field_sources}'
{
  "data": [
    {
      "id": "8272c985-6b1c-4396-ba58-1f1689ec70c4",
      "slug": "it-engineer-f-m-x-8272c985",
      "title": "IT Engineer (f/m/x)",
      "company": "liveeo-gmbh",
      "company_slug": "liveeo-gmbh",
      "company_logo": "https://supabase-erioun.tzekos.eu/storage/v1/object/public/company-logos/logos/liveeo.png",
      "category": "Engineering",
      "category_confidence": 0.8,
      "city": "LiveEO GmbH Berlin",
      "location": "LiveEO GmbH Berlin (Onsite)",
      "remote": "on_site",
      "remote_inferred": false,
      "posted_at": "2026-09-11T14:13:39Z",
      "first_seen_at": "2026-09-11T12:29:04Z",
      "last_verified_at": "2026-09-11T12:59:13Z",
      "status": "live",
      "closed_at": null,
      "closed_reason": null,
      "apply_url": "https://liveeo-gmbh.jobs.personio.de/job/2793572",
      "source": "personio",
      "source_type": "ats",
      "provider_type": "employer_ats",
      "has_description": true,
      "field_sources": {
        "remote": "published",
        "employment_type": "absent",
        "category": "inferred",
        "seniority": "absent",
        "salary": "absent",
        "location": "inferred",
        "posted_at": "published",
… 10 more lines
```

_Read `field_sources` first, then the fields. A `remote` value with `field_sources.remote: "inferred"` is our reading of the location text, not the employer's statement — and `remote_inferred: true` says the same thing at the top level, always present, never omitted when false. Real response, fetched from `/public/jobs?limit=1` when this file was built (11 September 2026, 14:34 UTC)._

**See also**

- [require_fields — the honest subset](./ledger-provenance.md#require-fields)
- [Salary, specifically](./ledger-provenance.md#salary-provenance)
- [The job row](./api-fields.md#job-fields)

<a id="require-fields"></a>

## 2. require_fields — the honest subset

Ask for only the rows where every named field was published by the source. This is the answer to “only a small share of your rows have a salary”.

### 2.1 In depth

The complaint is real and the answer is not a denial. A minority of rows carry a salary the employer stated. `require_fields=salary` returns that minority — and every row in it carries a figure a human at the employer actually wrote, with no estimate anywhere in the response. A smaller, clean set is worth more than a large set you have to filter yourself and cannot audit.

It composes with everything else. `require_fields=salary,location&country=DE` is a perfectly reasonable query, and it is the shape of question the parameter exists for. It also adds a `completeness` block to the response, which tells you how many live rows carry each required field across the whole ledger — so you can see whether an empty page means your filters were narrow or the data is thin.

> **Check the per-country split before you plan around it** — The ledger-wide number hides enormous variation. `/public/coverage/countries` gives you `with_salary` and `described_pct` per country, keylessly. [The table is on this site](./ledger-coverage.md#countries-list).

### 2.2 Exact contract

| parameter | type | required | default | allowed values | range | comma-separated | description |
| --- | --- | --- | --- | --- | --- | --- | --- |
| require_fields | string | no | — | — | — | yes | Comma-separated. Returns only rows where EVERY named field is `published` in `field_sources` — a value the source carried, never one we derived. The response then also contains a `completeness` object saying how many live rows carry each of them. This is the answer to "only 6.2% of your rows have a salary". They do — and `require_fields=salary` returns 225,769 rows of which 100% carry a figure an employer actually wrote, with no estimate anywhere in the response. Check the per-country split at `/public/coverage/countries` before you spend a record. `category` and `seniority` are refused with 422: both are read off the job title by our classifier, so they are `inferred` by construction and no row can ever satisfy them. An empty page would look like a coverage problem; the error says what it is. `source_type` is accepted and currently matches nothing — the per-row classification exists in the schema and no live row carries one yet. The `completeness` block reports that as a count rather than leaving you to infer it from an empty page. |

_1 parameters for `GET /v1/jobs`, generated from `https://jobopportunitiesapi.org/openapi.json`. The spec is served by the running API and is the contract._

The `completeness` block is present **only** when you passed `require_fields`. `published_in_ledger` is per field and ledger-wide: the number matching your other filters as well is at most the smallest of them. It is deliberately not an exact count of your query, because running that aggregate per request is how an endpoint becomes a timeout.

The completeness block, from a real response

```json
{
  "required": ["salary"],
  "published_in_ledger": { "salary": 307345 },
  "labels": { "salary": "Salary stated by the employer" },
  "generated_at": "2026-08-22T19:53:52Z",
  "note": "Every row in `data` carries all of `required` as \"published\" in field_sources …",
  "by_country": "https://api.jobopportunitiesapi.org/public/coverage/countries"
}
```

### 2.3 Worked examples

German roles where the employer stated the salary — and nothing else

```bash
curl -s -H "Authorization: Bearer $JOA_KEY" \
  'https://api.jobopportunitiesapi.org/v1/jobs?require_fields=salary&country=DE&limit=5' \
  | jq '{completeness, rows: [.data[] | {title, company, salary_min, salary_max, salary_currency, salary_source}]}'
```

**See also**

- [Why require_fields=category is an error](./ledger-provenance.md#the-422)
- [Per-country coverage](./ledger-coverage.md#countries-list)
- [The salary fields](./api-fields.md#salary-fields)

<a id="the-422"></a>

## 3. Why require_fields=category is an error

category and seniority are inferred by construction, so no row can ever satisfy them. The API says so with a 422 instead of returning an empty page.

### 3.1 In depth

This reads like a bug and is a feature, which is exactly why it needs explaining rather than merely documenting. `require_fields` means “only rows where the source published this”. `category` and `seniority` are read off the job title by our classifier — there is no employer field behind them — so the honest answer to `require_fields=category` is “zero rows, and zero rows forever”.

An empty 200 would be indistinguishable from a coverage problem. A developer would reasonably conclude the ledger has no categorised roles, which is false: about 95% of live rows carry a category. The 422 says what is actually happening and points at the filter you wanted, which is `?category=` plus reading `category_confidence`.

### 3.2 Exact contract

The refusal, and what it tells you to do instead

```console
$ curl -s -H "Authorization: Bearer $JOA_KEY" \
  'https://api.jobopportunitiesapi.org/v1/jobs?require_fields=category'
{
  "error": "field_never_published",
  "message": "category is read off the job title by our classifier, so it is always
               inferred. Filter it with ?category= and read category_confidence.",
  "docs": "https://jobopportunitiesapi.org/api"
}
```

_Captured 2026-08-22. HTTP 422._

One related case behaves differently and is worth knowing. `require_fields=source_type` is **accepted** and currently matches nothing, because the per-row classification exists in the schema and no live row carries one yet. That is a “not yet”, not a “never”, so it is not an error — and the `completeness` block reports the zero as a count rather than leaving you to infer it from an empty page.

**See also**

- [422 — a value we will not guess at](./api-errors.md#error-422)
- [field_sources — per-field provenance](./ledger-provenance.md#field-sources)
- [Provenance and serving — source, provider, quality, poster type, status](./api-parameters.md#params-provenance-filters)

<a id="salary-provenance"></a>

## 4. Salary, specifically

Published when the source stated it in a field, inferred when we read a real figure out of the advert text. Never a model's estimate, under any parameter.

### 4.1 In depth

Salary is the field most people want and the field most sources omit, which makes it the field most tempting to fabricate. This API does not. There is a salary estimator elsewhere in the group that produces modelled figures for internal use, and the projection that builds this ledger wraps every salary column in a condition that refuses to emit an estimated one. It is not a policy someone has to remember; it is a `CASE WHEN` in the query that builds the data.

- **`salary_source: structured`** — The source published it in a field. `field_sources.salary` is `published`.
- **`salary_source: parsed_description`** — A real figure quoted in the advert body, which we read out and normalised. We chose the number and the period, so `field_sources.salary` is `inferred`.
- **No `salary_source`** — There is no salary. `field_sources.salary` is `absent`.

The `has_salary` parameter respects this distinction rather than papering over it. `has_salary=true` (and its alias `structured`) returns only source-published figures — the meaning it has always had, kept so that adding parsed salaries did not silently change the results of a query someone already shipped. `has_salary=any` additionally includes the parsed ones.

### 4.2 Exact contract

| parameter | type | required | default | allowed values | range | comma-separated | description |
| --- | --- | --- | --- | --- | --- | --- | --- |
| has_salary | string | no | — | true, structured, any | — | no | `true` (and `structured`) returns only rows whose salary the SOURCE published — the meaning this parameter has always had, kept so that adding derived salaries does not change the results of a query you already ship. `any` also includes figures we read out of the advert text (`salary_source: parsed_description`, reported as `inferred`). AI estimates are never published under any value. |
| min_salary | number | no | — | — | — | no | Lower bound on `salary_min_annual_eur`. Selects ONLY rows with structured salary we could normalise — 2.0% of the ledger — so this is a narrow filter by nature, not a broken one. |
| max_salary | number | no | — | — | — | no | Upper bound on salary_min_annual_eur. Same 2.0% caveat as min_salary. |

_3 parameters for `GET /v1/jobs`, generated from `https://jobopportunitiesapi.org/openapi.json`. The spec is served by the running API and is the contract._

`salary_min_annual_eur` is a derived comparison field: `salary_min` converted to an annual EUR figure so that a row quoting USD per hour and one quoting GBP per year can be compared. It is **null** whenever the period or the currency is unrecognised — never guessed, because reading an hourly rate as an annual salary is wrong by a factor of about 2080. `min_salary` and `max_salary` filter on it, which is why they select only the small share of rows with a normalisable figure.

Conversion uses the indicative rates published on `/v1/meta/freshness`, not a settlement rate. If you are doing anything financial with the number, take the raw `salary_min`, `salary_currency` and `salary_period` and convert them yourself.

**See also**

- [The salary fields](./api-fields.md#salary-fields)
- [require_fields — the honest subset](./ledger-provenance.md#require-fields)
- [GET /v1/meta/freshness](./endpoints-meta.md#endpoint-meta-freshness)

---

## Where to go next

This file is part of **The ledger**. Others in the same group:

- [What the ledger is](./ledger-what-joa-is.md) — Employer-direct openings, kept as a record rather than a feed. What that phrase actually commits us to, and who it suits.
- [The data model](./ledger-data-model.md) — live + withheld + closed = ledger_rows, and it reconciles exactly. Almost every misunderstanding about this product traces back to this one equation.
- [Sourcing and refusals](./ledger-sourcing.md) — The three source classes, the providers inside each, the redistribution flag that gates them, and the sources we fetch for ourselves and never republish.
- [Coverage and honesty](./ledger-coverage.md) — How to read /public/coverage, why the weak numbers are published as prominently as the strong ones, and what measured_at and stale actually mean.
- [Employer opt-out and takedowns](./ledger-optout.md) — How a site owner removes themselves, why the removal is verified rather than taken on trust, and how it propagates to every endpoint.

Always useful:

- [index.md](./index.md) — the map of every file here
- [BUILD-A-SITE.md](./BUILD-A-SITE.md) — the paste-whole brief for building against this API
- [quickstart.md](./quickstart.md) — zero to a first authenticated response
- [api-errors.md](./api-errors.md) — every status code and whether to retry it
