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.
packages/pipeline/FOR UPDATE SKIP LOCKEDThe pipeline coordinates two processing stages: harvesting (downloading laws from wetten.nl) and enrichment (adding machine-readable logic via LLM).
| Module | Purpose |
|---|---|
job_queue.rs | Job creation, claiming (FOR UPDATE SKIP LOCKED), completion, failure with auto-retry |
law_status.rs | Per-law status tracking through 11 states |
harvest.rs | Harvest execution - download XML from BWB, convert to YAML |
enrich.rs | Enrichment execution - call LLM to add machine_readable sections |
worker.rs | Polling loops for harvest and enrich workers |
models.rs | Data types: Job, LawEntry, JobType, JobStatus, LawStatusValue, Priority |
config.rs | Configuration from environment variables |
db.rs | Connection pool creation and migration runner |
error.rs | Error types (PipelineError) |
Workers claim jobs atomically using PostgreSQL’s FOR UPDATE SKIP LOCKED, so multiple workers can safely process jobs concurrently without blocking each other.
When a job fails and has attempts remaining (attempts < max_attempts), it returns to Pending for retry. Default max_attempts is 3.
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.
Each law in the corpus progresses through processing states:
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.
The harvest worker:
The enrich worker:
machine_readable sections.enrichment-progress.json (polled every 10s)law_entries.coverage_score is the cumulative fraction of articles with machine_readable; the per-run delta rides in the job result)enrich/opencode)One LLM session cannot enrich a large law (hundreds of articles) within the
session/RSS limits. With ENRICH_MAX_ARTICLES_PER_RUN = N (default 15, 0
disables chunking) each enrich run processes at most N articles, in document
order, from a worker-owned cursor:
enrich_cursor + enrich_cursor_path) persists in the
.enrichment.yaml on the enrich/{provider} branch. It only applies when
recorded for the same YAML path and within bounds; otherwise it resets to 0
(covers new law versions and legacy metadata).law_complete = false), its status stays
enriching and a continuation job is created in the same database
transaction as the job completion (respecting the unique active-enrich-job
index), so there is never a law in enriching without an active/pending job.machine_readable sections when the agent records a chunk_report in
.enrichment-result.yaml that references at least one article of the
chunk’s window; a chunk without any output (or with an empty/unrelated
report) fails retryable (never terminal).ceil(articles_total / N) successful runs,
independent of LLM behavior; the last chunk marks the law enriched.deliver=task) always run whole-law (chunking off).A window is one law and one article range: the translation pass plus the
feedback rounds of the three gates. Every one of those calls used to be a cold
CLI process that read the law, the context brief, the skills and the schema
again, up to seven starts per window. ENRICH_SESSION_REUSE decides whether
they share one agent session instead. It applies to the claude provider only;
the worker picks the session id itself and passes --session-id on the first
call and --resume after that.
window (default): every call in the window continues the same session.
Each resumed feedback prompt opens with an instruction to read the file from
disk before answering. A gate is meant to be a fresh look at what stands
there, and an agent that remembers writing it can otherwise defend its own
choice instead of reading the finding.repair: the translation pass and the schema gate share a session, the
checks and marking gates run cold. A schema error is a fact about the file;
those two gates ask for judgement.off: every call its own cold process, the behaviour before this existed.The session never crosses a window: a continuation chunk opens its own. An agent that kept everything it wrote would carry half a large law into the last chunk, which costs more than starting over.
Whether reuse is cheaper has a number behind it. A resumed round pays every
turn over the context the translation pass ended at, so it wins only if
knowing the law already shortens the round. Every call is therefore accounted
(agent_calls in the job result: step, whether it was resumed, input/output/
cache-read tokens and cost), with the window total beside it (usage) and the
mode that produced them (session_reuse). enrich-once --session-reuse runs
the same loop locally and prints the table.
The LLM provider is configurable via LLM_PROVIDER (default: opencode). Provider-specific paths and models are set via environment variables (e.g., OPENCODE_PATH, OPENCODE_MODEL).
The LLM subprocess runs with a stripped environment (allowlisted vars only) for security.
| Variable | Default | Purpose |
|---|---|---|
DATABASE_URL | required | PostgreSQL connection string |
DATABASE_MAX_CONNECTIONS | 5 | Connection pool size |
REGULATION_REPO_PATH | ./regulation-repo | Output directory |
WORKER_POLL_INTERVAL_SECS | 5 | Queue poll interval |
WORKER_MAX_POLL_INTERVAL_SECS | 60 | Max backoff interval |
WORKER_JOB_TIMEOUT_SECS | 1200 (20 min) | Job execution timeout |
WORKER_ORPHAN_TIMEOUT_SECS | 1800 (30 min) | Orphan detection timeout |
LLM_PROVIDER | opencode | LLM provider selection |
LLM_TIMEOUT_SECS | 600 (10 min) | LLM execution timeout |
ENRICH_MAX_ARTICLES_PER_RUN | 15 | Max articles per enrich run (chunked enrichment); 0 disables chunking |
ENRICH_FEEDBACK_ROUNDS | 1 | Feedback rounds per gate; 2 or checks=2,marking=3 |
ENRICH_SESSION_REUSE | window | Session sharing within one window: window, repair or off |
ENRICH_STEPS | every step | Which steps of the chain to run, e.g. reconcile for the closing pass alone |
ENRICH_WINDOW_MODE | entries | What a window is: entries counts entries, layers uses the dependency layers of RFC-033 |
ENRICH_WINDOW_CONCURRENCY | 1 | Windows run side by side, each in its own copy of the checkout and its own agent session |
ENRICH_CONTEXT_BRIEF | on | 0 withholds the context brief the worker writes beside the law |
ENRICH_MAX_RSS_MB | 3500 | Memory ceiling for the agent subprocess |
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.
Two tables with PostgreSQL enums:
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).
Migrations run automatically at startup using an advisory lock for coordination.
Integration tests use testcontainers to spin up ephemeral PostgreSQL instances; no local database setup is required.
An exploration by Bureau Architectuur of the Dutch Ministry of Economic Affairs and Climate Policy into the possibilities of transparent, executable legislation.
GitHub repository
How it works
Stay informed
Roadmap (Dutch)
Documentation
Research
Bureau Architectuur
Ministry of Economic Affairs and Climate Policy