Skip to main content

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:

stateMeaning
declaredThe ruleset declares the dataset and nothing is out of date (including a dataset nothing has been pushed to yet).
schemaChangedThe dataset is declared, but its newest data version was validated against a different declaration. A push is due.
notDeclaredThe 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​

StatusCodeWhen and what to do
400VALIDATION_FAILEDThe request body is not one of the two shapes above, or names a member this contract does not have. Fix the request.
401AUTHENTICATION_REQUIREDThe key is missing, malformed or revoked.
403FORBIDDENThe key lacks the dataset_write scope.
404NOT_FOUNDThe dataset id does not exist or belongs to another tenant — the two are indistinguishable by design.
409DATASET_NOT_DECLAREDThe ruleset does not declare this dataset any more. Nothing can be pushed until an author declares it again.
409DATASET_SCHEMA_CHANGEDThe declaration is not the one the push was prepared for. Read it again and push again.
409DATASET_DELTA_SCHEMA_MISMATCHA delta cannot follow a change of the declaration. Push a full snapshot.
413REQUEST_BODY_TOO_LARGEThe body exceeds 2 MiB (2,097,152 bytes). See below.
422noneOne 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\"]"
}
]
}
  • type and title describe the kind of problem. type is a stable identifier, not a URL to fetch: every deployment sends it as https:// followed by rule-line.dev/problems/dataset-push-rejected (the example above writes the host as <problem-type-host>). Branch on status and the diagnostics, not on type or title.
  • totalRejectedRows and totalProblems are exact counts of everything wrong in the push.
  • diagnostics lists at most 100 problems. When totalProblems is 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 (rows for a full push, upsert or delete for a delta), the 0-based row index within that section, the field path inside the row, and the row's key when its key fields are themselves valid (otherwise null). expected and actual are 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:

  1. 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 pin datasetVersion on 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.
  2. 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 through datasetVersion; 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.

  3. 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].

  4. 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​