Pipeline

A PostgreSQL-backed job queue that orchestrates and tracks the law-processing workflow.

The pipeline is a PostgreSQL-backed job queue and law status tracking system that orchestrates the law processing workflow.

Overview

  • Language: Rust
  • Location: packages/pipeline/
  • Database: PostgreSQL
  • Key feature: Reliable concurrent job processing with FOR UPDATE SKIP LOCKED

Architecture

The pipeline coordinates two processing stages: harvesting (downloading laws from wetten.nl) and enrichment (adding machine-readable logic via LLM).

Workers

Pipeline

XML

YAML

claim/complete

claim/complete

machine_readable

enriched YAML

Job Queue

Law Status Tracker

Harvest Worker

Enrich Worker

BWB / wetten.nl

Corpus Juris

LLM Provider

Modules

ModulePurpose
job_queue.rsJob creation, claiming (FOR UPDATE SKIP LOCKED), completion, failure with auto-retry
law_status.rsPer-law status tracking through 11 states
harvest.rsHarvest execution: download XML from BWB, convert to YAML
harvest_request.rsThe one entry point for “request a harvest”, shared by the pipeline API and the admin API
traject_harvest.rsHarvest of a law for one traject, delivered as a review task instead of written to the corpus
enrich.rsEnrichment execution: call the LLM to add machine_readable sections
enrich_v2/The model-free parts of enrichment: checks, capability plan, reference graph, closing pass
document_convert.rsUploaded document (docx, PDF and others) to a markdown werkdocument
law_convert.rsUploaded PDF or Word document to a base-law YAML, followed by a task-flow enrich
law_migrate.rsLift a law file to schema v0.7.0
markings.rs, untranslatables.rsPersist the markings and untranslatables the enrichment agent reports
tasks.rsPersonal review tasks that tie a finished job to the account that requested it
feature_flags.rsRead and write the shared feature_flags table
worker.rsPolling loops for the harvest and enrich workers
health.rsThe small HTTP health endpoint inside each worker
api/Handlers of the pipeline-api service
models.rsData types: Job, LawEntry, JobType, JobStatus, LawStatusValue, Priority
config.rsConfiguration from environment variables
db.rsConnection pool creation and migration runner
error.rsError types (PipelineError)

Binaries

BinarySourceWhat it is
regelrecht-harvest-workersrc/bin/harvest_worker.rsClaims harvest and traject_harvest jobs
regelrecht-enrich-workersrc/bin/enrich_worker.rsClaims enrich, document_convert and law_convert jobs, which share the LLM CLI environment and the hourly budget
regelrecht-pipeline-apisrc/bin/pipeline_api.rsInternal HTTP service, see Pipeline API
law-checksrc/bin/law_check.rsRuns the deterministic enrichment checks over law files, without database, git or model. Exits 1 on schema errors; --strict fails on every finding; --corpus adds the cross-law binding check
law-sourcesrc/bin/law_source.rsCompares a law file’s text with the official BWB toestand. Exits 1 when an article drifts, is missing or is fabricated. --rewrite replaces the text with the official one and keeps machine_readable per article
law-migratesrc/bin/law_migrate.rsLifts law files to schema v0.7.0 and validates the result. A required field it cannot fill is reported, never guessed; it writes only with --write
enrich-oncesrc/bin/enrich_once.rsRuns the real enrichment loop against a directory on disk, without database or git, so a worker change can be tried on one law locally

Run the four tools from packages/ with cargo run -p regelrecht-pipeline --bin <name> -- <args>. The usage of each is in the doc comment at the top of its source file.

Job Lifecycle

create_job

claim_job (FOR UPDATE SKIP LOCKED)

complete_job

fail_job (retries left)

fail_job (max attempts reached)

reap_orphaned_jobs (timeout)

reap_orphaned_jobs (no retries)

Pending

Processing

Completed

Failed

Workers claim jobs atomically using PostgreSQL’s FOR UPDATE SKIP LOCKED, so multiple workers can safely process jobs concurrently without blocking each other.

Automatic Retries

When a job fails and has attempts remaining (attempts < max_attempts), it returns to Pending for retry. Default max_attempts is 3.

Orphan Reaping

Jobs stuck in Processing beyond the orphan timeout (default: 30 minutes) are reset to Pending or marked Failed, which is how a crashed worker is handled.

Law Status Tracking

Each law in the corpus progresses through processing states:

harvest job created

worker claims job

harvest succeeds

job retries exhausted

re-queued (fail count below threshold)

fail count reaches threshold

enrich job claimed

enrichment succeeds

job retries exhausted

re-queued (fail count below threshold)

fail count reaches threshold

no consolidated text

Unknown

Queued

Harvesting

Harvested

HarvestFailed

HarvestExhausted

Enriching

Enriched

EnrichFailed

EnrichExhausted

NotHarvestable

NotHarvestable is the terminal one. A work can have no consolidated text to harvest because it was withdrawn, is not yet in force, or has only been announced. The skip reason is uniform, so the status is a single value and the precise reason and date go into the harvest job’s result. The job is completed rather than failed, so it is never retried; a future law can be re-harvested by hand once its text appears.

Pipeline API

Besides the workers, the crate builds one HTTP service, pipeline-api (src/bin/pipeline_api.rs, image regelrecht-pipeline-api). It has no public address and no authentication of its own. The editor API forwards /api/harvest/* to it (see PIPELINE_API_URL on Editor API) and does the auth checks in front of it.

RoutePurpose
POST /harvestCreate a harvest job for one law
POST /harvest/batchCreate harvest jobs for several laws
GET /harvest/statusJob and law status
GET /harvest/searchSearch BWB for a law to harvest
GET /healthLiveness

Harvest Worker

The harvest worker:

  1. Polls the queue for pending harvest jobs
  2. Downloads law XML from BWB (wetten.nl)
  3. Converts XML to YAML via the harvester library
  4. Writes YAML to the corpus
  5. Creates enrich jobs for each LLM provider, but only when ENRICH_AUTO_ENQUEUE is on (it is off by default) and the law is not enrich_exhausted
  6. Creates follow-up harvest jobs for the laws the harvested text references

Two mechanisms pull in further laws, each with its own limit:

  • References in the text. Step 6 follows the external references the harvester finds in the XML. A follow-up job carries its parent’s depth plus one, and the chain stops at MAX_HARVEST_DEPTH (1000, in harvest.rs). That constant guards against runaway recursion and is not a tuning knob; the chain normally ends because every referenced law is already harvested or queued.
  • Related legislation from enrichment. After an enrich job, the related legislation the agent reports (delegated regelingen, cross-law sources, legal bases the harvester misses) is harvested only while the enrich job’s depth is below RELATED_HARVEST_MAX_DEPTH (default 2). This is the limit that bounds how far one enrichment can grow the corpus.

Enrich Worker

The enrich worker:

  1. Polls the queue for pending enrich jobs, within the hourly budget (ENRICH_HOURLY_LIMIT)
  2. Spawns an LLM CLI process to generate machine_readable sections
  3. Tracks progress via .enrichment-progress.json (polled every 10s)
  4. Computes coverage score (the stored law_entries.coverage_score is the cumulative fraction of articles with machine_readable; the per-run delta rides in the job result)
  5. Pushes the result to a per-provider branch (enrich/ followed by the provider name, opencode or claude) for review

The hourly budget fails closed. Without ENRICH_HOURLY_LIMIT, or with 0, the worker enriches nothing, so a forgotten variable cannot spend a subscription on the whole corpus. The cap counts runs for the worker’s own LLM_PROVIDER per clock hour (Europe/Amsterdam), and because it is counted in the jobs table it survives a restart.

Large laws and agent sessions

A large law does not fit in one agent session. The worker splits it into windows of at most ENRICH_MAX_ARTICLES_PER_RUN articles (default 15) and owns the cursor itself: it lives in .enrichment.yaml on the enrich/{provider} branch, each chunk pushes its own result, and the next chunk is queued in the same transaction that completes the current one. A law of N articles is done in at most ceil(N / 15) successful runs, whatever the model does. Task-flow enrichments (deliver: task) always take the whole law.

Within one window, ENRICH_SESSION_REUSE decides how the translation pass and the feedback rounds of the gates share an agent session: all of them (window, the default), only the schema repair (repair), or none (off). It applies to the claude provider and never crosses a window. Token use and cost of every call land in the job result (agent_calls, usage), which is how the modes are compared; enrich-once --session-reuse prints the same table locally.

The reasoning behind both, including why a window follows document order rather than “the next articles without machine_readable”, is in the doc comments on plan_chunk and SessionReuse in enrich.rs.

LLM Providers

LLM_PROVIDER selects opencode (the default) or claude. The binary and model come from OPENCODE_PATH / OPENCODE_MODEL or CLAUDE_PATH / CLAUDE_MODEL, with LLM_PATH and LLM_MODEL as the fallback for either.

The LLM subprocess runs with a stripped environment (allowlisted vars only) for security.

Configuration

Database and workers

VariableDefaultPurpose
DATABASE_URLrequiredPostgreSQL connection string; DATABASE_SERVER_FULL is read as fallback
DATABASE_MAX_CONNECTIONS5Connection pool size
REGULATION_REPO_PATH./regulation-repoLocal checkout the workers write to
REGULATION_OUTPUT_BASEregulation/nlDirectory inside that checkout where harvested laws land
WORKER_POLL_INTERVAL_SECS5Queue poll interval
WORKER_MAX_POLL_INTERVAL_SECS60Max backoff interval
WORKER_JOB_TIMEOUT_SECS1200 (20 min)Job execution timeout
WORKER_ORPHAN_TIMEOUT_SECS1800 (30 min)Orphan detection timeout
WORKER_MAX_CONSECUTIVE_RESOURCE_FAILURES5Consecutive fork or out-of-memory failures after which the worker exits, so the platform restarts it with a clean process table
EXHAUSTED_THRESHOLD10Consecutive failures after which a law becomes harvest_exhausted or enrich_exhausted
RELATED_HARVEST_MAX_DEPTH2Depth up to which related legislation from enrichment is harvested
HEALTH_PORT8000Port of the worker’s health endpoint
PORT8000Listen port of pipeline-api

The corpus checkout is configured by the corpus library (packages/corpus/src/config.rs). Without CORPUS_REPO_URL the workers run without one.

VariableDefaultPurpose
CORPUS_REPO_URLnoneCorpus repository to clone and push to
CORPUS_REPO_PATH/tmp/corpus-repoWhere the clone lives
CORPUS_BRANCHderivedBranch to use; a preview deployment derives its own from HOSTNAME / DEPLOYMENT_NAME
CORPUS_GIT_TOKENnoneToken for the central corpus repository only, never for a traject repository
CORPUS_GIT_AUTHOR_NAME, CORPUS_GIT_AUTHOR_EMAILregelrecht-harvester, noreply@minbzk.nlCommit author

Enrichment

VariableDefaultPurpose
ENRICH_HOURLY_LIMIT0 (paused)Enrich runs per clock hour for this worker’s provider; must be set for the worker to enrich at all
ENRICH_NIGHT_MULTIPLIER1Multiplier on the hourly limit between 00:00 and 08:00 (Europe/Amsterdam)
ENRICH_AUTO_ENQUEUEofftrue (or 1, yes, on) makes a completed harvest queue enrich jobs; otherwise enrichment is requested explicitly through POST /api/enrich-jobs on the Harvester Admin API
LLM_PROVIDERopencodeopencode or claude
LLM_PATH, LLM_MODELnoneFallback binary and model for either provider
OPENCODE_PATH, OPENCODE_MODELopencode, provider defaultBinary and model for opencode
CLAUDE_PATH, CLAUDE_MODELclaude, provider defaultBinary and model for claude
LLM_EFFORTnoneReasoning effort passed to the provider (claude --effort)
LLM_TIMEOUT_SECS600 (10 min)Ceiling per agent call
CLAUDE_CODE_OAUTH_TOKENnoneSubscription token for the claude provider; several comma-separated tokens are rotated over time
SKILLS_DIR/opt/skillsSkills baked into the image, linked into the checkout when the directory exists
ENRICH_MAX_ARTICLES_PER_RUN15Max articles per enrich run (chunked enrichment); 0 disables chunking
ENRICH_FEEDBACK_ROUNDS1Feedback rounds per gate; 2 or checks=2,marking=3
ENRICH_SESSION_REUSEwindowSession sharing within one window: window, repair or off
ENRICH_STEPSevery stepWhich steps of the chain to run, e.g. reconcile for the closing pass alone
ENRICH_WINDOW_MODEentriesWhat a window is: entries counts entries, layers uses the dependency layers of RFC-033
ENRICH_WINDOW_CONCURRENCY1Windows run side by side, each in its own copy of the checkout and its own agent session
ENRICH_CONTEXT_BRIEFon0 withholds the context brief the worker writes beside the law
ENRICH_MAX_RSS_MB3500Memory ceiling for the agent subprocess
CODE_COMMITemptyCommit of the running image, recorded in the enrichment metadata

LLM_TIMEOUT_SECS is a ceiling per agent call, and one run makes several: a translation pass, a feedback round per gate, the closing pass and the final schema gate. The worker lowers it when the job budget cannot hold that many, so raising LLM_TIMEOUT_SECS without raising WORKER_JOB_TIMEOUT_SECS buys nothing.

Database Schema

The pipeline crate owns the migrations (packages/pipeline/migrations/) for the whole platform database, so the editor API’s tables (accounts, trajects, notes, settings, feature flags) are created here too. The two the pipeline itself runs on:

jobs - Job queue with retry tracking, priority ordering, and JSONB payload/result/progress columns. Partial index WHERE status = 'pending' for efficient claiming.

law_entries - Per-law status tracking with foreign keys to harvest/enrich jobs and a coverage score (0.0–1.0).

Next to those, tasks and job_blobs carry review tasks and their payloads, and markings and untranslatables hold what enrichment reported.

Migrations run automatically at startup using an advisory lock for coordination.

Testing

just pipeline-test # Unit tests (no Docker) just pipeline-integration-test # Integration tests (Docker + testcontainers)

Integration tests use testcontainers to spin up ephemeral PostgreSQL instances; no local database setup is required.

Further reading

  • Harvester - the BWB law downloader used by harvest jobs
  • Architecture - where the pipeline fits in the system

RegelRecht

An exploration into transparent, executable legislation, and one of the three projects in the starting selection of the Nederlandse Digitale Dienst.

Links

GitHub repository
How it works
Stay informed
Roadmap (Dutch)
Documentation
Research

Contact

regelrecht@minbzk.nl

Part of

Nederlandse Digitale Dienst
Ministry of Economic Affairs and Climate Policy