Push reference data
Reference data is the master data a ruleset looks things up in during a run: creditors, chart-of-accounts entries, product catalogs. A ruleset declares each dataset it reads (its row shape, its key and its indexed fields); your system keeps the rows current by pushing them. Every push creates a new immutable, numbered data version — nothing is ever edited in place, so a run can always be traced to the exact rows it read.
Pushing needs an API key carrying the dataset_write scope (see
Authenticate). A key without it is refused with
FORBIDDEN. Finding a dataset's id
uses the ordinary read scope, so an integration that looks the id up and then
pushes carries both. The scope only lets a key
push rows: no operation creates a dataset or changes its declaration — that
stays in the ruleset the author edits.
Find the dataset's id
A push is addressed by the dataset's id, not its name. List your datasets and narrow them by the ruleset's exact name and the dataset's exact name:
import { client, listDatasets } from '@one-unity/rule-line-sdk';
client.setConfig({
baseUrl: 'https://api.rule-line.example.com',
auth: 'rl_key_your_api_key',
});
const found = await listDatasets({ query: { ruleset: 'invoice-routing', name: 'creditors' } });
if (found.error) {
throw new Error(`dataset lookup failed: ${found.error.code}`);
}
const dataset = found.data.items[0];
if (!dataset) {
throw new Error('no dataset "creditors" is declared by ruleset "invoice-routing"');
}
console.log(dataset.id, dataset.state, dataset.currentSchemaHash);
GET /datasets?ruleset=invoice-routing&name=creditors HTTP/1.1
Host: api.rule-line.example.com
Authorization: Bearer rl_key_your_api_key
{
"items": [
{
"id": "44444444-4444-4444-8444-444444444444",
"rulesetName": "invoice-routing",
"name": "creditors",
"declared": true,
"state": "declared",
"currentSchemaHash": "9f2c1d7a5b3e48c6a0d4f8e2b6c1a3d5e7f90a2b4c6d8e0f1a3b5c7d9e1f3a5b",
"latestVersion": {
"version": 3,
"kind": "delta",
"rowCount": 2,
"schemaHash": "9f2c1d7a5b3e48c6a0d4f8e2b6c1a3d5e7f90a2b4c6d8e0f1a3b5c7d9e1f3a5b",
"createdAt": "2026-09-29T08:30:00.000Z"
}
}
],
"nextCursor": null
}
state tells you whether a push can succeed and whether one is due:
state | Meaning |
|---|---|
declared | The ruleset declares the dataset and nothing is out of date (including a dataset nothing has been pushed to yet). |
schemaChanged | The dataset is declared, but its newest data version was validated against a different declaration. A push is due. |
notDeclared | The ruleset no longer declares the dataset, or declares it in a form a push cannot be validated against. A push is refused. |
currentSchemaHash identifies the declaration a push would be validated
against; latestVersion.rowCount is the newest version's row count. Both are
null when they do not apply.
Push a full snapshot
A full push replaces the whole row set. Each row is a JSON object that must
match the declared row schema exactly: every required field present, no
undeclared field, and every value of its declared type. Nothing is coerced — a
number sent where the declaration says text is a rejected row, not a converted
one.
import { client, pushDatasetVersion } from '@one-unity/rule-line-sdk';
client.setConfig({
baseUrl: 'https://api.rule-line.example.com',
auth: 'rl_key_your_api_key',
});
const pushed = await pushDatasetVersion({
path: { datasetId: '44444444-4444-4444-8444-444444444444' },
body: {
mode: 'full',
reason: 'Nightly export of the creditor master',
rows: [
{ id: 'C-1001', vatId: 'DE000000001', name: 'Example Supplies GmbH' },
{ id: 'C-1002', vatId: 'DE000000002', name: 'Sample Freight AG' },
],
},
});
if (pushed.error) {
if ('diagnostics' in pushed.error) {
throw new Error(
`${pushed.error.totalRejectedRows} row(s) rejected, first problem: ` +
`${pushed.error.diagnostics[0]?.code} at row ${pushed.error.diagnostics[0]?.row}`,
);
}
throw new Error(`push failed: ${pushed.error.code}`);
}
console.log(`created version ${pushed.data.version} with ${pushed.data.rowCount} row(s)`);
reason is optional free text of 1 to 200 characters. It is stored with the
version, together with who pushed it, in the dataset's append-only history.
Equivalent raw HTTP
POST /datasets/44444444-4444-4444-8444-444444444444/versions HTTP/1.1
Host: api.rule-line.example.com
Authorization: Bearer rl_key_your_api_key
Content-Type: application/json
{
"mode": "full",
"reason": "Nightly export of the creditor master",
"rows": [
{ "id": "C-1001", "vatId": "DE000000001", "name": "Example Supplies GmbH" },
{ "id": "C-1002", "vatId": "DE000000002", "name": "Sample Freight AG" }
]
}
Expected response
A successful push answers 201 with the new version's facts:
{
"datasetId": "44444444-4444-4444-8444-444444444444",
"version": 4,
"kind": "full",
"baseVersion": null,
"rowCount": 2,
"schemaHash": "9f2c1d7a5b3e48c6a0d4f8e2b6c1a3d5e7f90a2b4c6d8e0f1a3b5c7d9e1f3a5b",
"contentHash": "3b7e0a9d5c1f4826b8d0e2a4c6f81357a9bdf1e3c5072849ab6cd8ef01234567",
"actor": {
"kind": "apiKey",
"id": "55555555-5555-4555-8555-555555555555"
},
"reason": "Nightly export of the creditor master",
"createdAt": "2026-09-29T09:00:00.000Z"
}
version counts up from 1 within a dataset. schemaHash is the hash of the
declaration the rows were validated against, and contentHash identifies the
version's full content. baseVersion is null for a full snapshot.
Push a delta
A delta push changes only what differs from the dataset's latest version, so
a large reference set does not have to travel in full every time. upsert
carries rows to add or replace — a row is matched to an existing one by its
key. delete carries key objects: objects holding exactly the declared key
fields and nothing else. Both members are optional.
import { client, pushDatasetVersion } from '@one-unity/rule-line-sdk';
client.setConfig({
baseUrl: 'https://api.rule-line.example.com',
auth: 'rl_key_your_api_key',
});
const pushed = await pushDatasetVersion({
path: { datasetId: '44444444-4444-4444-8444-444444444444' },
body: {
mode: 'delta',
reason: 'Supplier C-1003 closed; C-1002 changed its VAT id',
upsert: [{ id: 'C-1002', vatId: 'DE000000012', name: 'Sample Freight AG' }],
delete: [{ id: 'C-1003' }],
},
});
if (pushed.error) {
if ('diagnostics' in pushed.error) {
throw new Error(`${pushed.error.totalProblems} problem(s) in the delta`);
}
throw new Error(`push failed: ${pushed.error.code}`);
}
console.log(`version ${pushed.data.version} is based on version ${pushed.data.baseVersion}`);
The raw HTTP request differs only in its body:
POST /datasets/44444444-4444-4444-8444-444444444444/versions HTTP/1.1
Host: api.rule-line.example.com
Authorization: Bearer rl_key_your_api_key
Content-Type: application/json
{
"mode": "delta",
"reason": "Supplier C-1003 closed; C-1002 changed its VAT id",
"upsert": [{ "id": "C-1002", "vatId": "DE000000012", "name": "Sample Freight AG" }],
"delete": [{ "id": "C-1003" }]
}
A delta version reports "kind": "delta" and the version it was applied on top
of in baseVersion. A delta cannot follow a change of the dataset's
declaration: if the newest version was validated against a different
declaration than the current one, the push is refused with
DATASET_DELTA_SCHEMA_MISMATCH
and you push a full snapshot instead.
Guard a push with expectedSchemaHash
Your export was prepared for one shape of the data. If an author changes the
dataset's declaration between your export and your push, the rows would be
validated against a shape you never saw. Pass the currentSchemaHash you read
from listDatasets (or from getDataset) as
expectedSchemaHash, and the push is refused with
DATASET_SCHEMA_CHANGED
when the current declaration no longer has that hash:
const guarded = await pushDatasetVersion({
path: { datasetId: '44444444-4444-4444-8444-444444444444' },
body: {
mode: 'full',
expectedSchemaHash: dataset.currentSchemaHash ?? undefined,
rows: [{ id: 'C-1001', vatId: 'DE000000001', name: 'Example Supplies GmbH' }],
},
});
On that refusal, read the dataset's current declaration with getDataset,
adapt your export, and push again. expectedSchemaHash is optional; leaving it
out means "validate against whatever is current".
Which declaration a push is validated against
A push is always validated against the latest draft's declaration of the dataset. When the ruleset has no draft, it is validated against the active version's declaration. No request parameter selects another schema — a body naming a schema, a ruleset version or anything else this page does not list is refused rather than ignored.
This is what lets the two sides move independently. While a draft leaves the declaration unchanged, the active ruleset reads your new data immediately. When a draft changes the declaration, the active ruleset keeps reading the last data version that was valid under its own declaration until the draft is activated, and activation binds to the newest data version that matches the new declaration. Your first push after such a change is therefore what makes the draft activatable.
Handling errors
| Status | Code | When and what to do |
|---|---|---|
| 400 | VALIDATION_FAILED | The request body is not one of the two shapes above, or names a member this contract does not have. Fix the request. |
| 401 | AUTHENTICATION_REQUIRED | The key is missing, malformed or revoked. |
| 403 | FORBIDDEN | The key lacks the dataset_write scope. |
| 404 | NOT_FOUND | The dataset id does not exist or belongs to another tenant — the two are indistinguishable by design. |
| 409 | DATASET_NOT_DECLARED | The ruleset does not declare this dataset any more. Nothing can be pushed until an author declares it again. |
| 409 | DATASET_SCHEMA_CHANGED | The declaration is not the one the push was prepared for. Read it again and push again. |
| 409 | DATASET_DELTA_SCHEMA_MISMATCH | A delta cannot follow a change of the declaration. Push a full snapshot. |
| 413 | REQUEST_BODY_TOO_LARGE | The body exceeds 2 MiB (2,097,152 bytes). See below. |
| 422 | none | One or more rows do not match the declaration. Nothing was written. See below. |
A body that is too large
A push runs as one database transaction, so its body is capped at 2 MiB — more
than an ordinary request carries, but bounded so a single push stays quick. The
request is refused before it is parsed. A reference set that does not fit in one body goes in as a full
snapshot of the first part followed by delta pushes for the rest, each within
the cap.
Rejected rows
A push is all or nothing. When any row is invalid, no version is created and
the response is 422 with a dedicated body — it is not the standard problem
envelope and carries no code:
{
"type": "https://<problem-type-host>/problems/dataset-push-rejected",
"title": "The pushed rows do not match the dataset declaration, so no new version was created.",
"status": 422,
"totalRejectedRows": 2,
"totalProblems": 3,
"diagnostics": [
{
"code": "DATASET_ROW_WRONG_TYPE",
"severity": "error",
"blocking": true,
"path": "vatId",
"expected": "string",
"actual": "number",
"row": 1,
"section": "rows",
"key": "[\"C-1002\"]"
},
{
"code": "DATASET_ROW_MISSING_REQUIRED_FIELD",
"severity": "error",
"blocking": true,
"path": "name",
"row": 4,
"section": "rows",
"key": "[\"C-1005\"]"
},
{
"code": "DATASET_ROW_UNKNOWN_FIELD",
"severity": "error",
"blocking": true,
"path": "phone",
"row": 4,
"section": "rows",
"key": "[\"C-1005\"]"
}
]
}
typeandtitledescribe the kind of problem.typeis a stable identifier, not a URL to fetch: every deployment sends it ashttps://followed byrule-line.dev/problems/dataset-push-rejected(the example above writes the host as<problem-type-host>). Branch onstatusand the diagnostics, not ontypeortitle.totalRejectedRowsandtotalProblemsare exact counts of everything wrong in the push.diagnosticslists at most 100 problems. WhentotalProblemsis larger, fix what you see and push again to reveal the rest — the totals tell you whether 100 is "the first 100 of 4,000" or "all there are".- Each diagnostic names its
section(rowsfor a full push,upsertordeletefor a delta), the 0-basedrowindex within that section, the fieldpathinside the row, and the row'skeywhen its key fields are themselves valid (otherwisenull).expectedandactualare type names and appear only for a type problem. - A diagnostic never contains a pushed value. It points at the problem without echoing your data back, so a rejected push cannot leak a personal or financial value into your logs.
Which version a ruleset reads
The data version a run reads is decided when the run starts and is then frozen
for that run, so a push that lands mid-run never changes it. A lookup step
that names no version reads the newest data version whose schema hash matches
the ruleset version's own declaration; a lookup that pins a version reads
exactly that one. The trace of every run records which version its lookups
read. To see what a ruleset version currently reads, use getDataset and its
activeReads member — see Browse reference data.
Personal data in reference data
Reference data often describes people: a sole trader as a creditor, a contact person on a supplier. What removing such a person does and does not do:
-
Erase a person from master data by pushing a version without them. Push a full snapshot that omits the row, or a delta that deletes its key. The new version, and every version after it, no longer contains the person. The older versions that still hold the person are a separate matter, and two cases are not released by a push at all:
- A pinned version. If a ruleset version that has ever been active pins
a data version through
datasetVersion, and that data version contains the person, it keeps the person's rows indefinitely. Activating a newer ruleset version, whether it pins another data version or none, does not release the pin, and no push removes the version. A lookup that names no version reads the newest matching data version, so a newer ruleset version can stop reading the person, but the pinned version still holds the rows. To keep personal data out of a permanent pin, do not pindatasetVersionon a dataset that holds personal data in a ruleset version you activate. - A changed declaration. A push is validated against the latest draft's declaration, or the active version's when there is no draft (see Which declaration a push is validated against). If the declaration has changed since the data version that holds the person, your erasure push is validated against the new declaration, and the older data version stays the newest one under the old declaration. Because a ruleset version that has ever been active declares the old declaration, that data version keeps the person indefinitely. While the change is still only in a draft, the active ruleset also keeps reading that older version until the draft is activated. Push the erasure while the declaration is still the one the person's version was validated against, before you save a draft that changes the dataset's declaration. Once a declaration that has ever been active has been replaced, a push cannot reach the last data version validated against it.
- A pinned version. If a ruleset version that has ever been active pins
a data version through
-
Older versions lose their rows after the raw-content window. A superseded data version keeps its rows for the platform's raw-content retention window (30 days), counted from the moment a newer version superseded it, after which the rows are removed. The version's history entry stays — it is append-only — and reports
rowsRemovedAt, but it holds no row content. A version is kept beyond the window while it is the newest version for a declaration that any ruleset version that has ever been active declares, or that the latest draft declares; while any ruleset version that has ever been active pins it throughdatasetVersion; or while it is the dataset's newest version. "Ever been active" includes a ruleset version that a newer activation has since replaced: a replaced version's declaration and pins are never released. -
Row copies in traces purge on the same window. When a lookup returns rows, the trace of that run holds a copy of them. That copy is raw content: it is purged after the same window, or immediately when the run itself is erased. The values the lookup searched for (an IBAN or an invoice number taken from the run's input, say) are purged with it: the record of the lookup keeps the field and operator of each match, but the value is replaced by
[purged]. -
Row keys kept as decision metadata remain until the run is erased. To keep every decision reconstructable, a run's trace keeps a small record of each lookup that survives the raw-content window: the dataset, the data version, the fields and operators of the resolved query (without the searched-for values), the number of rows returned, and up to a capped number of the returned rows' keys. If a key is itself personal data — a customer number that identifies one person, say — it stays in that record until the run is erased. Pushing a version without the person does not touch it.
The two cases under point 1 are limits of today's behavior, not procedures: a push cannot remove a pinned version or the last version under a replaced declaration.
The fourth point is a documented gap, not a feature. Erasure today is scoped to a run: you can erase the runs that read the person's row, and the metadata goes with them, but there is no single operation that erases one data subject across every run. Subject-wide erasure will close that gap. Until then, if a row key is personal data, plan on erasing the affected runs as part of removing the person.
If you can avoid it, choose keys that are not personal data (an internal surrogate id rather than a person's name or tax number) — then this gap does not apply to you.
See also
- Browse reference data — list datasets, read version history and page through a version's rows.
- The
POST /datasets/{datasetId}/versionsreference for the complete request and response schemas.