Repository navigation
Releases
v0.36.0
No changes of its own: the pack relocks at dirigent 0.36.0 and moves to the same number.
Requires dirigent 0.36.0.
The dirigent 0.36.0 notes follow.
Released 2026-11-04. Every package in the workspace moves to 0.36.0 together, and so do
dirigent-dhis2, dirigent-contrib, dirigent-fhir and dirigent-integration.
Before upgrading. Nothing in the database changes, so an instance on 0.35.0 upgrades in place.
Two changes can refuse what 0.35 accepted, each marked Breaking below: a schema's $ref to a
code the instance does not hold is refused at plan, and a schema another schema references can no
longer be deleted.
A $ref reaches another stored schema. A schema can point at another one the instance holds,
by code: {"$ref": "station-code"}, with station-code#/$defs/x pointing inside it. The reference
is resolved against the referring schema's $id first, as JSON Schema resolves every $ref; a bare
code reaches the schema stored under it, and an address (https://example.org/schemas/unit.json)
reaches a stored schema only when that schema's own $id is that address, so a link to an outside
schema that ends in a stored code is not quietly checked against the local one. A run pins every
schema its document names and every one they reach this way, validate.schema follows them, and a
pipeline that checks against a schema through another's $ref is listed among its users. Nothing
is ever fetched. docs/json-schema.md has the rules.
Breaking: references are checked. Plan refuses a reference that reaches no stored schema
(document.schema_reference_unknown, at the place the document names the schema), and deleting a
schema another one references is refused (schema.referenced, naming them).
A value is checked against a schema without a run. POST /api/v1/schemas/$check checks a value
against a stored schema or an unsaved body, the way validate.schema does, answering every failure
with its JSON pointer into the value, the keyword that refused it and the readable reason.
dg schema check CODE [FILE|-] and the MCP tool check_schema say the same. Any signed-in
account may use it; it writes nothing.
A schema has a page of its own. A row on the Schemas screen opens /schemas/<code>: the schema
in an editor the height of the page, in a Schema panel with its window, and Details in the side
panel, which starts shut, holding the rendered description, Edit for the name and the description
with a Save of their own, and the pipelines that check against it. Try a value opens a full-screen
tester: the schema, a value, and what the check found, each failure marked in the value where it is,
checked as it is typed against the schema text in the editor, saved or not. Generate an example
builds a value from the schema -- its own examples, default or const where it has them, else its
types, enums, bounds, formats, local $refs and simple patterns. New asks for the code, the name and
the description and opens the new schema's page.
The window button is at the top. Every pane that opens in a window has its button in its panel's
head where there is one, as the graph and the schema editor do, and otherwise in its own top-right
corner, rather than at the foot of the pane. The side panel's toggle sits at the screen's right
edge, at the end of the top bar while the panel is shut and of the panel's strip while it is open,
and the rail can be collapsed from beside the logo.
In dirigent-dhis2: dhis2-period takes every DHIS2 period type -- bi-weekly, the weekly
types starting on another day, bi-monthly, six-monthly (and from April or November), quarterly from
November, and the financial years. It took yearly, monthly, daily, quarterly and weekly alone.
v0.35.0
No changes of its own: the pack relocks at dirigent 0.35.0 and moves to the same number.
Requires dirigent 0.35.0.
The dirigent 0.35.0 notes follow.
Released 2026-11-04. Every package in the workspace moves to 0.35.0 together, and so do
dirigent-dhis2, dirigent-contrib, dirigent-fhir and dirigent-integration.
Before upgrading. The baseline migration is edited in place, so dg db upgrade does not carry
this release's schema: alert_rules gains a declared column. Recreate the state
(dg dev --wipe-state, or a fresh database), or add the column by hand. Three changes break a
caller, each marked Breaking below: a pipeline.run child that ends badly fails with codes
of its own, a connection created awaiting its secrets has its settings validated, and in
dirigent-dhis2 the metadata export's counts is totals.
A pipeline document declares its own alert rules. A top-level alerts: mapping, keyed by
rule code, takes what a pipeline-scoped rule takes through the API: event, connection,
template, body, importance, throttle, active, name and description. Plan refuses a
connection the instance does not hold or no notifier delivers through, and a code another owner
holds (alert.owned_elsewhere), and warns on a template name the alert context does not give.
Its diff carries alerts_added, alerts_removed and alerts_changed. Apply creates, rewrites
and deletes the rules the document declares and leaves every other rule alone; pausing is
instance state and survives an apply. A declared rule is updated or deleted only through its
document (alert.declared, 409); pause and resume stay open, and the rule carries declared.
The shipped alerts-declared example shows one.
dg connection set-secret CODE FIELD --stdin reads the secret from a pipe, as in
op read ... | dg connection set-secret dhis2-play basic_password --stdin, so an agent's shell
can complete a connection created over MCP. The answer of the MCP create_connection names that
form under supply_with.piped.
A refused dg apply writes a validation record at error, with the document and its
problems, after its apply record, so a filter on the refusal kinds sees it.
The unsafe-block remedies name every refused block, as
--enable-unsafe docker.run,shell.run, not shell.run alone.
A run read on its own names the step it failed at. get_run, dg runs show and a run's
cancel, pause and resume answer the failed step's error, error_code and failed_step, as the
listing did. The Run tab leaves a failure to the failure card beside it.
Alert templates see the failure under run too. run.error, run.error_code,
run.error_params and a new run.failed_step carry what the top-level error, error_code and
failing_step do. Creating or changing a rule warns on a template name the alert context never
gives (alert.template_name_unknown, answered under the rule's warnings and as
alert_rule.warning records), and docs/reports.md lists every name a template is given.
Breaking: a connection created awaiting its secrets has its settings validated. A setting the
kind refuses, such as a sql url with no driver, is refused at create over the API and MCP with
the same reason the CLI gives, unless supplying the secrets would cure it. A sql connection to
SQLite is then complete at once rather than awaiting a password.
Pausing an alert rule is described as what it does: a paused rule ignores events until it is
resumed, and nothing is queued meanwhile.
Plan warns on a misspelt output field. A reference such as ${steps.fetch.output.statuss},
or a report template reading step.fetch.output.statuss, whose step's block publishes a closed
output schema without that field, is warned about as document.output_field_unknown, naming
the fields the output has. A read into an open value, a mapping or a list is not judged, and
nothing is refused.
A bare number where text is wanted is told to be quoted. period: [202501, 202502] was
refused as "is not valid under any of the given schemas"; it now says "202501 and 202502 are
numbers; write each quoted, as '202501'". A value that fits no branch of a union names the
shapes the field takes, or where the nearest one refused it, in a step's config, a parameter
schema, a carried schema and a run's parameters alike.
Plan refuses a storage source outside the artifact root. A file:// uri that resolves
outside the instance's artifact root planned clean and then failed every poll; plan refuses it
at steps.<source>.source.uri with the same artifacts.outside_root.
Breaking: a pipeline.run child that ends badly fails the step with a code of its own. A
strict step whose child ends completed_with_errors fails as base.child_completed_with_errors,
classed rejected and not retried; a child that ends failed or cancelled fails it as
base.child_run_ended. Both carry the child's run, pipeline, status and
child_error_code in their params. Both were run.remote_job_said, classed unknown, so a
reply or an alert matching that code for a child run now matches these.
v0.34.0
fhir.measure uses its parameters. The engine's measure evaluator took no parameter values, so a library's defaults always won; the pack now hands the step's parameters to every definition the measure evaluates (dhis2w-fhir#65 filed upstream).
Delete, patch, update by id and operations. fhir.update takes id as well as an identifier; fhir.patch applies a JSON Patch or a FHIRPath Patch by id or by search; fhir.delete deletes by id, or by a search it counts first and refuses past max (default 1), deleting what it counted one id at a time; fhir.operation invokes a named operation such as $everything on the system, a type or one resource. A search that matches everything is refused for delete and patch.
Requires dirigent 0.34.0.
The dirigent 0.34.0 notes follow.
Released 2026-11-03. Every package in the workspace moves to 0.34.0 together, and so do
dirigent-dhis2, dirigent-contrib, dirigent-fhir and dirigent-integration.
Before upgrading. The baseline migration is edited in place, so dg db upgrade does not carry
this release's schema: notifications.event becomes the notification_event enum, which adds
test. Recreate the state (dg dev --wipe-state, or a fresh database), or change the column by
hand. Six changes break a document or a caller, each marked Breaking below: a document's
connections: block may not hold a secret; a step's connection must be of a kind its block takes;
an http check is healthy on a 2xx or 3xx alone; a test alert is its own event; a reference
indexed with brackets is refused; and rabbitmq.publish fails a message no queue takes. The
connection_kind records dg blocks list writes change shape too.
An interrupted program runner exits quietly every time. A Ctrl-C that reached a jq runner
between two requests was held until the runner's stdin closed, and then raised outside the code
that turns it into exit 130, so the runner exited 1 with a bare KeyboardInterrupt. The runners
install their own SIGINT handler before anything else and end with 130 wherever the signal lands.
dg dev generates a secret key when a project has none. A project made with
dg init --template documents has no .env, so the first dg connection create against the
dg dev started there was refused secret.key_missing. On a state holding no sealed connection
secret, and with no DIRIGENT_SECRET_KEY configured, dg dev now generates one into the
project's .env, readable by its owner alone, and its starting record names the file in
secret_key_written, never the key.
Breaking: a document's connections: block may not hold a secret. Plan refuses a carried
connection whose config sets a field its kind declares secret, such as basic_password or
hmac_secret, or a URL with a password in it, with document.carried_connection_secret at
connections.<code>.config.<field>. dg validate, dg run --local and every plan refuse it the
same way; a server already refused any carried connection at apply. The shipped examples that
carried the playground's demo credentials (http-headers-and-auth-connection,
webhook-post-hmac, webhook-signed) now name the playground connection, which a local run
gets from --connections examples/connections.yaml; connections-referenced-vs-carried carries
its connection without one.
A connection kind's fields are discoverable. dg connection kinds lists every kind with its
required fields, its secret ones and its credential forms, and dg connection kinds KIND (or
--local) describes one field by field: type, required, secret, default, credential form. The API
serves them at GET /api/v1/connection-kinds and GET /api/v1/connection-kinds/{kind}, MCP adds
list_connection_kinds and get_connection_kind, and dg connection create --help points at
them. Breaking: the connection_kind records dg blocks list writes carry the same shape,
fields[] in place of config_schema and secret_fields.
A SQL URL naming no driver is told the one its dialect takes. sqlite:///x.db was refused
with a suggestion of sqlite+asyncpg://, a driver that does not exist; sql.no_async_driver
now names the async drivers of the URL's own dialect, sqlite+aiosqlite:// for SQLite, and its
suggested param replaces driver_name.
Breaking: an http connection check is healthy on a 2xx or a 3xx alone. A 400 at the base
URL used to report healthy, proving only that the host answered. Any 4xx is now unhealthy, with
its status and the path in detail: a 401 or a 403 says the service refused the credential the
connection holds, or wanted one it holds none of, and another 4xx says to set health_path to an
address the service answers. A connection that checked green against a 4xx needs a health_path.
The webhook kind keeps its own check, a HEAD that proves the endpoint is reachable.
Breaking: a test message is its own event, test. dg alerts test queued its message as
run_succeeded. It is now test in the queue (NotificationOut.event is a NotificationEvent,
the four rule events and test), in a webhook's payload and in the log channel's event_kind,
and the log line carries no stand-in run facts. A rule still binds to the four run events alone.
Breaking: a reference indexed with brackets is refused at plan. ${steps.x.output.value[0]}
passed plan and failed at run time; a reference reads an element as a dotted segment, and plan
now refuses brackets with document.reference_bracket_index, naming the dotted form to write,
${steps.x.output.value.0}.
The block catalog marks unsafe blocks. A catalog entry carries unsafe (a block that runs
code on the worker, such as docker.run and shell.run) and unsafe_enabled, whether this
instance's enabled_unsafe_blocks names it, in GET /api/v1/blocks and /blocks/{id}, the
block records of dg blocks list and dg blocks show (with --local, against the settings
dg reads), MCP's list_blocks and get_block, and the Blocks screen, where the row and the
panel wear an unsafe chip and the facts say whether it is enabled.
A plan's diff names every top-level section that changed. A document whose only change was its
report: used to plan as an update with every flag false. DiffSummary gains report_changed
and requires_changed, and settings_changed now covers priority and importance beside the
name, description, tags and concurrency. The API's plan, dg apply's records and its console
lines, MCP's plan_document, the editor's plan and the audit trail's changed parts all carry
them.
Breaking: a step's connection must be of a kind its block takes, and plan and apply refuse one
that is not. A connection field declares the kinds it accepts with
Reference("connection", kinds=(...), carrying=(...)), published as x-dirigent-accepts beside
x-dirigent-ref: one of the named kinds, or one of any kind whose config carries every named
field. sql.query and sql.execute take sql, kafka.* kafka, rabbitmq.* rabbitmq,
git.checkout git, the docker blocks docker, webhook.post's sign_with http, and
http.request, http.ready and webhook.post an http connection or one of any kind carrying
base_url, so a pack's dhis2, fhir, climate or chap connection still fits them. The
refusal is document.step_connection_wrong_kind, or document.step_connection_for_alerts for a
kind an alert channel delivers through: a webhook connection named by webhook.post used to
pass plan and apply and then fail the run on a URL with no scheme. webhook.post does not take a
webhook connection: that kind is one url for alert delivery, and the block posts to an http
connection's base_url plus path. dg validate checks a connection the document carries
offline, and against the server checks every one. A document already stored that names a
connection of the wrong kind is refused at its next apply; it would have failed at run time.
A plain ConnectionRef still takes every kind, so a pack's blocks are unchanged until they say
which. validate_against_catalog now takes connections as a mapping of code to kind, where None is a
connection held whose kind the caller does not know and is not checked.
A block's unanticipated failure says what went wrong. run.block_raised names the block and
then the exception's own text, scrubbed of the step's secrets, where it used to say only that the
step's log had the cause.
Breaking: rabbitmq.publish fails a message no queue takes. The publish was already
mandatory, but the channel answered a returned message with success, so a routing key that named
no queue on the default exchange reported published: 1 for a message the broker dropped. The
channel now raises on a return, and the step fails as rejected with queues.rabbit.nothing_bound.
require_route: false publishes without mandatory, for a fanout nobody may be subscribed to
yet. A broker's nack fails the step as transient with queues.rabbit.publish_nacked, where it was
reported as nothing bound. kafka.produce already refused a topic the cluster lacks and waits for
every in-sync replica under its default acks: all, and is unchanged.
v0.33.0
The connection asks for its credential. dg connection create fhir ... offers a bearer token or a username and password, both optional.
Requires dirigent 0.33.0.
The dirigent 0.33.0 notes follow.
Released 2026-11-03. Every package in the workspace moves to 0.33.0 together, and so do
dirigent-dhis2, dirigent-contrib, dirigent-fhir and dirigent-integration.
Before upgrading. The baseline migration is edited in place, so dg db upgrade does not
carry this release's schema: recreate the state (dg dev --wipe-state, or a fresh database), or
make the changes by hand. The new table is audit_entries. connections gains awaiting_secrets
(a boolean, false by default); notifications gains rule_code, and its alert_rule_id foreign
key becomes nullable with ON DELETE SET NULL; sources gains failed, sources.failures is
renamed poll_failures, and input_outcome gains failed; runs gains the index
ix_runs_created_at. Four changes break a document or a caller, each marked Breaking below: a
list or object inside text renders as JSON; a block's limits are refused at plan; a source's
counters are renamed and recounted; cancelling a settled run is refused. A dg of another version
run inside a project that pins dirigent now refuses to run: use uv run dg, or
uv tool upgrade dirigent-cli for the global one.
dg dev and dg server draw one startup banner at a terminal. The ready record carries the
version, the addresses (UI, API, docs, /mcp), on dg dev the sign-in and the state path, the
dev log file, and every pack loaded with its version and how many blocks, connection kinds and
source kinds it contributes; at a terminal the console formatter draws it as one banner under the
mark. In a pipe, with --json or DIRIGENT_LOG_FORMAT, the records are written as they are and
nothing else. dg server now writes starting and ready too.
Ctrl-C stops dg dev within a bound, a second Ctrl-C at once. dg dev handles SIGINT and
SIGTERM itself: the first drains the worker and stops the API, closing connections still open once
the worker has drained, and gives up after 10 seconds, naming what it cancelled; a second exits at
once. Program runners such as jq's start outside the terminal's process group, so a Ctrl-C no
longer reaches them or prints their traceback, and dg dev stops them on its way out. It hung
before whenever a stream was open, a --watch or an MCP session.
dg guards a project against another dg, and asks for a kind's credential. Inside a project
whose pyproject.toml pins dirigent-cli, a dg the pin does not allow refuses with
cli.version_mismatch, naming both versions; DG_ALLOW_VERSION_MISMATCH=1 runs it anyway. A
connection kind declares its credential_forms and whether one is credential_required, and
dg connection create asks which form to use and for each of its fields at a terminal; outside one,
a required credential left out is refused with the exact --set fields of each form. dg init's
README is a guide from the scaffold to a running instance, a connection, a first pipeline and an
agent over MCP, for each template. dg profile list and dg profile show say which profiles exist,
which is selected and where each token comes from, never the token; DG_PROFILE is documented.
An alert names its failing step and why. The log channel writes one structured line per alert
-- event_kind, run_id, pipeline, status, failing_step (and item), a one-line error
and its error_code, trigger, duration_ms, url -- where it wrote the text body into one
field. A default body opens with the failing step and its error and writes params as JSON, and a
custom body template reads failing_step, failing_item, error and error_code.
The run graph zooms on a plain wheel, and the account row is a menu. On the run's screen, whose
graph fills the room above the terminal, a plain wheel zooms about the pointer; the pipeline's page
keeps a plain wheel for the page and Ctrl or Cmd with the wheel for the graph. The rail's account
row is a round avatar, the name and a chevron, and its menu carries Settings, Keyboard shortcuts,
Appearance (Light, Dark, System) and Sign out; the top strip's mode flip is gone.
An agent or an operator sets up a connection, and an admin gives it its secrets. The MCP server's create_connection (offered to an operator and an admin, never a viewer) creates a connection with its settings alone. It sends awaiting_secrets: true on POST /connections, and the API then refuses any field the kind declares secret by name and never by value (server.secrets_withheld), still refuses every other setting the kind's model refuses, and stores the settings alone. An operator may now POST /connections too, without a secret: one carrying a secret value is refused by field name (server.secrets_need_admin, 403), a connection that cannot work without a secret (its model refuses the settings alone, or its kind's credential_required names a form none of which is complete) is stored awaiting it, and any other, such as http without auth, is stored complete and usable. Supplying a secret, updating, deleting and checking a connection stay an admin's. Every read carries awaiting_secrets; a step, a source, an alert and $check refuse such a connection (secret.connection_awaiting_secrets) until an update whose config the model accepts whole completes it. The tool answers the secret fields to supply and the command that does it: the new dg connection set-secret CODE [FIELD]..., which asks for the kind's credential form as dg connection create does and prompts for each secret without echoing it, or the connection's dialog, where the Connections screen draws it as awaiting secrets. dirigent-client's connections.create takes awaiting_secrets.
An agent over MCP reaches what it had to ask a person for. get_run_report reads the markdown report document a run rendered, and whether it rendered one, as dg runs report RUN --markdown does. get_run_profile is dg runs profile: the critical path and its queued, running and waiting time. list_schedule_firings is dg schedule firings, and list_alert_deliveries is dg alerts queue, narrowed to a run or a status. Alert rules are written with create_alert_rule, update_alert_rule, pause_alert_rule, resume_alert_rule and delete_alert_rule, which an operator is offered, and named schemas with create_schema, update_schema and delete_schema, which only an admin is, as the API allows. dirigent-client's alerts.notifications takes status, and the run-timing model dg runs profile computes moved from dirigent_cli.timing to dirigent_client.timing.
edit_pipeline changes a pipeline without sending it whole. It takes the current version's text exactly as export_pipeline answers it, makes each {old, new} replacement in turn, each old occurring exactly once, and plans the result, applying it unless plan_only. An edit that misses, or matches more than once, refuses every edit, names each one, and stores nothing.
Every MCP answer is bounded. An answer is held under 48 KB of JSON: a listing too large keeps the rows that fit, drops its next and says which limit asks for exactly those, and anything else is cut to the start of its JSON with a note saying how to ask for less. Listings answer 20 rows by default, not 50. list_examples lists each example's code, name, shelf, tags and blocks, with summary=false for the whole row; get_example, export_pipeline and get_run_report answer a long text a stretch at a time from offset, with next_offset, and export_pipeline now answers an object carrying the text rather than the bare text. run_attempts shows each output as output_bytes and an output_preview unless include_output, and get_attempt_output reads one, inline or from storage, or with path the part a JSON pointer names.
Alert rules stay out of pipeline documents. A rule's code is the instance's rather than a pipeline's, a global rule watches every pipeline, and a rule is removed by a person rather than by a document that stopped declaring it.
Runs grouped by pipeline. The Runs screen has a View switch between the list and By pipeline: one row per pipeline that ran in the last hour, 24 hours or 7 days, with the newest run's state, a count per state it has runs in, its 30 most recent runs as dots oldest first, each a link to its run, when it last ran, and the median duration of the settled ones. A row opens the list of that pipeline's runs over the same window. The view and window are in the address, /runs?group=pipeline&window=24h; the pipeline, tag and status filters carry over, the status asking for each pipeline's newest run. The same answer is GET /runs/$summary?since=24h (RunSummary, with latest=<state> and tag), dg.runs.summary(), dg runs summary (runs.summary records), and the runs_by_pipeline MCP tool.
A refused config says what is wrong, in our words. A step whose resolved config its block refuses fails with run.config_invalid: one short sentence, `delay` must be at most 30s, with every problem in error_params.problems[] (code, message, location, params), and the run's screen lists them by field. A literal config is refused the same way at apply, at steps.<step>.config.<field>. Every validation message reads in our own words under the same validation.* codes (must be at most 30s, is required, must be a valid integer), with a duration or size limit written as a document writes one.
How a run was started is said once. The runs list, the pipeline page's Recent runs, the run's header and its Trigger section name a person or a token by the label alone (agent (token mcp-trial), not api token · agent (token mcp-trial)), a schedule, backfill or source as its kind and code, and a rerun as `rerun of by ...
v0.32.0
Follows dirigent 0.32.0; nothing in the pack changed.
Requires dirigent 0.32.0.
The dirigent 0.32.0 notes follow.
Released 2026-11-03. Every package in the workspace moves to 0.32.0 together, and so do
dirigent-dhis2, dirigent-contrib, dirigent-fhir and dirigent-integration. The schema is
unchanged, so a 0.31.0 state opens as it is.
A graph is looked at, not only shown. On the run's screen and the pipeline's page, dragging the ground pans, and a pinch or Ctrl/Cmd and the wheel zooms about the pointer while a plain wheel still scrolls the page. A Layout control sets direction, stepped, orthogonal or curved edges, and spacing for the visit; nothing is stored, and the next screen opens on the default. The graph opens in a window, where the wheel zooms, and a box chosen there is chosen on the page. On the run's screen the graph fills the room above the docked terminal and refits when the seam is dragged. A box is as wide as its step's code, measured before layout, so data_elements_by_domain is no longer cut, and a fan-out's item keys wrap.
A chosen step is in the address. /runs/<id>/<step> and /pipelines/<code>/<step> open with that step chosen and its panel open, with ?item= and ?attempt= for one element or one try; choosing the ground goes back to the run's or the pipeline's own address. A step panel shows each attempt's id and each fan-out item's id, copyable. A settled run's header carries Rerun alone.
A pipeline's page shows what it did. A Recent runs panel lists the newest runs, each a link, with All runs opening the Runs list filtered to the pipeline. A pipeline whose document declares a report: shows the latest settled run's report, the way the run's Report tab draws it, with the runs before it a link away. GET /runs takes settled=true|false, and dirigent-client's runs.list takes settled. A markdown table whose header cells are all empty draws no header row, and the window button on a long report stays in reach wherever it is scrolled.
A local instance's first admin is admin / admin. dg init --template local with no password from the flag, DIRIGENT_BOOTSTRAP_ADMIN_PASSWORD or the form creates admin / admin, as dg dev seeds on an empty state. Any other password is still at least 8 characters, and --template compose refuses admin. The AGENTS.md it writes names the MCP tools the same loop runs through when the instance's /mcp is connected.
/mcp is rate limited per token. A new setting, mcp_rate_per_minute (300), bounds how many requests one API token makes to /mcp a minute across all its sessions, with a minute's worth allowed as one burst. A request past it is answered 429 with Retry-After (server.mcp_rate). The count is per token, not per address, and per server process.
A basic credential a service echoes does not reach a step's output either. A server quoting the request's Authorization: Basic ... header put user:password in base64 in the output, which the scrub did not recognise. A connection holding a password beside a username, or <prefix>_password beside <prefix>_username such as basic_password and basic_username, now adds that pair in standard and URL-safe base64, padded or not, to what the scrub replaces, so the header reads Basic ***. The encoding is judged on its own, so a basic credential whose password is a word, such as DHIS2's demo admin / district, is scrubbed in its base64 form while the bare word stays.
v0.31.0
The FHIRPath verbs are documented on JSON that is not FHIR. transform.fhirpath, map.fhirpath and filter.fhirpath read any JSON value and need no server. The block reference says what to expect on data with no schema behind it, and the new example fhir-fhirpath-plain-json runs with nothing but the pack installed.
Writes, redirects and searches are held tighter. A write refuses a resourceType that is no FHIR type name before anything is sent. A server quoting the Authorization it was sent never puts the credential in details. fhir.search holds its pages to max_total together and folds them off the event loop. A redirect's body is never read, and a request follows at most five redirects.
A credential cannot sit in a URL. A connection's base_url refuses a user, a password, a query and a fragment, and fhir.search params refuses credential-named parameters. Either moves to the connection's sealed credential.
Requires dirigent 0.31.0 and dhis2w-fhir-engine 1.38.3.
The dirigent 0.31.0 notes follow.
Released 2026-11-02. Every package in the workspace moves to 0.31.0 together, and so do
dirigent-dhis2, dirigent-contrib, dirigent-fhir and dirigent-integration.
Before upgrading. The baseline migration changed (pipeline_versions.shape,
step_attempts.remote_cancel_since), so recreate the database or add the two columns by hand. A
document or connection holding a credential in a header, a query parameter or a URL is refused and
moves to the connection's sealed credential: a stored document fails at its next run, a stored
connection when it is opened.
A secret a service echoes does not reach a step's output. A Location on an unfollowed redirect or a body quoting the request put a connection's token in the attempt's output and every step that read it. The engine now walks what a block returns before it is stored or handed on, off the event loop, and replaces every spelling of every secret the attempt opened, inside strings and dict keys, leaving types and shape alone. A mixed secret, such as a token or a generated password, is replaced wherever it occurs in that output. A secret that could be ordinary data is left out of this scrub in all its spellings, so a letters-only or digits-only secret a service echoes, such as DHIS2's demo district, or one under four characters, stays in the output in plain text; logs and failures still scrub it. What a block writes straight to storage, a capture, a remote handle and a source's poll are not covered.
A credential does not sit in a header map, a query map or a URL. http.request, webhook.post, http.ready, the http and webhook connections, an S3 endpoint_url, and a sql, rabbitmq, git or docker URL each wrote one somewhere stored as written. headers refuses a name such as Authorization, Cookie or X-Api-Key; query, and a parameter written in url or path, refuses api_key, access_token, client_secret and their kin, while page_token, nextToken, continuation_token, sort_key and a bare key pass; a URL refuses a user and a password. Each is refused at apply, literal or reference alike, and again when the step starts for what a reference resolved to, and no refusal repeats the value. The http and webhook connections gain a sealed credential, sent in credential_header after credential_scheme, or as the query parameter credential_query, and a request hook keeps either on the connection's own origin. This breaks a document or a stored connection that wrote one: a stored document fails at its next run, a stored connection when it is opened, and either moves to credential. A URL an earlier step's output supplies is exempt from the parameter refusal, decided from the unresolved config: url: ${steps.x.output.link} runs with its ?token=, shown as *** in the attempt's stored config, logs and failures, while a parameter the document writes, such as ?token=${steps.x.output.t}, or anything through ${params...} is refused. The HTTP client's own INFO line, which writes each request's whole URL, is floored at WARNING in the process log. A sql or rabbitmq URL and an ssh git remote keep their user, and an https git remote that carried one, which used to be stripped, is refused. kobo-submissions-to-csv now names a kobo connection and has lost its token and server parameters. In the packs, the climate, chap, fhir and dhis2 base_url refuse a user, a password, a query or a fragment, fhir.search refuses a credential-named parameter, and climate gains credential, credential_header, credential_scheme and credential_query.
A name bound outside a program reads as its own colour in the editor. jq's $name and FHIRPath's %name and $this were a pale violet close to the text colour. They now wear a stronger violet in every palette, dark and light.
playground.generate and playground.arrive are bounded, and run off the event loop. json, pystr_format and their kin reached names outside the provider allowlist, the class-wide seed among them; provider arguments and field counts had no bound, and generation held the worker's loop past a step's timeout. Every dispatch now goes through the allowlist. One generation makes at most 100,000 provider calls and 16 MiB of records in 30 seconds (playground.generation_past_its_bound), and an argument saying how much to make asks for at most 10,000 (playground.provider_refused).
A source's curl line quotes a method that is not a bare word. The UI's copyable line left the method unquoted, relying on the server's list of methods.
A listing reads a shape stored at apply, and the schema changed. pipeline_versions gains shape, written when a version is stored, so GET /pipelines, GET /pipelines/{code} and the MCP tools that call them no longer revalidate each document, every template in it included, on the event loop on every read. The baseline migration is edited in place, so dg db upgrade does not carry the column: recreate the database, or add shape by hand with each version's step shape, as an existing row with an empty list draws no miniature.
An open MCP stream does not keep its session past the idle time. An open GET stream counted as a request in flight, so mcp_session_idle never closed its session. The stream is now ended after that long, and the session counts from there. A client that still wants one reconnects, which authenticates its token again; the SDK's client does.
A program a pack nests is held as a top-level one is. A ${...} in a template, a compose file, FHIRPath, CQL, jq or a shell string under a list, an object or a named definition used to be spliced into the program's text, since only top-level fields were read. Those four media types now refuse it wherever a config holds them, at apply and when the step starts. A nested jq program binds it as a variable, and a nested shell string takes it from the environment. A reference standing where a schema holds programs under it is refused (document.step_reference_for_a_program, reference.for_a_program). No block in this repository, dirigent-fhir, dirigent-contrib or dirigent-dhis2 nests one, so nothing changes for them. dirigent_plugin gains media_places_of and MediaPlace.
An MCP token is offered only the tools its role may call. plan_document was offered to a viewer and check_connection to an operator, and the API refused both. Each role now has a server of its own: a viewer's leaves out what writes and plan_document, whose dry run is an operator's route, and an operator's leaves out check_connection. DG_MCP_READ_ONLY=1 offers what a viewer is offered, so it no longer offers plan_document. mcp_max_sessions now counts each of the three roles apart. In dirigent_mcp, build() takes offers in place of read_only, and Caller carries offers in place of may_operate.
dg answers a bad settings file, setting or server URL with a record. A malformed dirigent.yaml read as settings and a .env setting pydantic refuses end in cli.settings_refused, which names the field and leaves out the value, and every profile refusal is now a cli.profile_refused record rather than Typer's panel, a URL holding a control character included. dg reads its settings before any command runs, so a bad one refuses every command but --version and a bare --help.
One MCP token cannot hold every session. A new setting, mcp_max_sessions_per_token (32), bounds what one token may hold within its role's mcp_max_sessions, so one token cannot lock its role's other callers out. A session past it is answered 503.
A signature timestamp no date can hold is refused. A 12-digit X-Dirigent-Timestamp past the year 9999 answered an unauthenticated caller 500 with a logged traceback. It is now refused like any other bad timestamp, source.signature_timestamp.
One address cannot take every playground stream. One anonymous client could hold all 32 seats for up to 95 seconds and answer every other caller 503. An address now holds at most 8, an IPv6 address counting as its /64.
dg removes bidirectional controls before a terminal shows a value. U+202A to U+202E and U+2066 to U+2069 in server or checkout text could reorder what a terminal displays around them.
A standalone MCP server reads Host strictly. On a loopback bind, localhost:evil and [::1]evil.example were read as loopback, and a request with no Host passed. All are now refused 421 (mcp.host_not_loopback).
A standalone MCP server answers 502 when its instance cannot say whose a token is. A 5xx, an answer that is not dirigent's, or an instance that is not there answered a bare 500 with an unhandled exception. They now answer mcp.instance_unavailable, 502.
dg pipeline new refuses a starter that is no document. A malformed starter from the instance ended in a traceb...
v0.30.0
fhir.search hands its pages or resources to a fan-out. emit: pages lists one searchset
Bundle per page under bundles, and emit: resources lists every resource under resources, for
a following step's for_each; emit: bundle, every page folded into one Bundle, stays the default.
An empty search hands on an empty list, which skips the fan-out. New example
fhir-copy-page-by-page.
FHIRPath on the transform, map and filter verbs. transform.fhirpath, map.fhirpath and
filter.fhirpath evaluate a FHIRPath program on dirigent's verb frames with the engine's
evaluator. A value reaches the expression as a variable under variables, read as %name, never
spliced in; an unbound %name, or one that shadows a built-in such as %resource, is refused at
apply and at run. The program is parsed at apply and evaluated in a runner process, so a step
timeout kills it, and each answer is held to max_output (16MB). New example
fhir-fhirpath-high-readings.
fhir.validate, structural validation on the engine's R4 models. It validates a resource, a
Bundle or a list of resources against FHIR's JSON rules and, for the 18 types the engine models,
against those models. profile checks a claimed profile with the engine's conformsTo(); no
StructureDefinition is resolved, so profile constraints, cardinalities and bindings are not
checked. An invalid input fails as fhir.validate.invalid, rejected, or under
on_invalid: report answers valid false; either way the issues come with FHIRPaths and as an
OperationOutcome. It takes no connection. New example fhir-validate-before-copy.
fhir.cql and fhir.measure on the engine's CQL evaluator. fhir.cql evaluates a CQL library
written in the step over a Bundle or a list of resources, once per patient the data carries or
once without one; a value reaches the library only as one of its parameters. fhir.measure
evaluates a measure's population definitions per patient, nests them by the scoring
(proportion, ratio, cohort) before counting, and answers a summary MeasureReport. Both
compile at apply, evaluate in a runner process and are held to max_output; failed definitions
fail the step as fhir.cql.definitions_failed. New example fhir-measure-heavy-adults.
Docs. The pack installs from PyPI, fhir.search's params takes a string or a list of
strings, and the blocks and examples pages cover the new blocks.
Requires dirigent 0.30.0 and dhis2w-fhir-engine 1.38.3.
The dirigent 0.30.0 notes follow.
Released 2026-11-01. Every package in the workspace moves to 0.30.0 together, and so do
dirigent-dhis2, dirigent-contrib, dirigent-fhir and dirigent-integration.
The FHIR pack gains a search that feeds a fan-out, FHIRPath, validation, CQL and measures.
dirigent-fhir's main adds fhir.search's emit: pages | resources for a following for_each,
transform.fhirpath, map.fhirpath and filter.fhirpath on the verb frames, fhir.validate
(structural, on the engine's R4 models), and fhir.cql and fhir.measure (a CQL library, and a
measure answered as a MeasureReport), released with this version. application/fhirpath and
text/cql join the program media types in dirigent_common.programs, and a field publishing
either refuses a ${...} at apply (document.step_reference_in_fhirpath,
document.step_reference_in_cql) and when the step starts (reference.in_fhirpath,
reference.in_cql), as a template does: a value reaches the expression as a variable, and the
library as a parameter.
FHIRPath and CQL are highlighted where they are written. The web UI's program editor tokenises application/fhirpath and text/cql fields in the house inks, both palettes, with a %name or $this in the ink a jq $name wears.
Every instance serves MCP over HTTP at /mcp. An agent connects to the instance itself with
its own API token, claude mcp add --transport http dirigent https://<instance>/mcp --header "Authorization: Bearer …", and nothing is installed beside it. Each request acts as the token it
presents, a viewer token is offered only the tools that read, and every tool is a call to the
instance's own API, so the API decides what a role may do. A request without a token that resolves
is answered 401, and one a page on another origin caused is answered 403
(server.mcp_cross_origin). The session cookie does not authenticate there. mcp_enabled
(default on) turns it off, and mcp_max_body, mcp_session_idle and mcp_max_sessions bound it.
The stdio command uvx dirigent-mcp is unchanged, and both offer a new whoami tool.
dirigent-mcp --transport http serves MCP in front of a remote instance. It listens on
127.0.0.1:3334 and answers only a loopback Host, takes each caller's own bearer token and
forwards it to DG_URL, never DG_TOKEN, and refuses another origin's page. An address other
machines can reach needs --allow-remote.
More MCP tools, and resources to write a document against. list_connections,
get_connection and check_connection (secrets redacted; checking is admin only), list_schemas
and get_schema, list_alert_rules and get_alert_rule, get_source with an endpoint's full URL
and how a caller authenticates, and rotate_source_token, marked destructive. get_run answers
how a source's input arrived (via) and stored_bytes. Every tool's readOnlyHint,
destructiveHint, idempotentHint and openWorldHint now say what its call does. The resources
dirigent://schema/document, dirigent://blocks and dirigent://blocks/{block_id} serve the
format and the catalogue. GET /api/v1/alert-rules/{code} reads one rule, and the client's
alerts.rule() and blocks.document_schema() call it and GET /schema/document.
dirigent-fhir is on PyPI, and dg init --pack dirigent-fhir adds it. The FHIR pack, a fhir
connection kind and the fhir.read, fhir.search, fhir.create, fhir.update and
fhir.transaction blocks for one FHIR R4 server, is published at 0.29.0 and pinned at the
runtime's version like dirigent-dhis2 and dirigent-contrib, in the flag and the form. It moves
with each release from now on, and the integration make test covers it.
A polled source keeps a cadence shorter than the poll tick. A worker used to look for due
sources only every source_poll_tick (5s), so a sensor source with poll: 1s ran every five
seconds and "poked again at once after a success" waited out the tick. A worker now sleeps until
the earliest polled source falls due, at most source_poll_tick and never less than 50
milliseconds. source_poll_tick is now the longest a worker goes without looking.
dg source list, show and inputs render at a terminal. They wrote NDJSON whatever the
terminal, -o console included. A source now reads as one line: its state, its step, the
pipeline, what it listens to, and what it has taken, with a warning level when it last failed. An
input reads as what came of it, its run or its refusal and reason. In a pipe they write the same
source and source.input records as before.
A pipeline.run step says which step of its child failed. A child run that failed on a step
used to surface as run … failed: no reason was recorded. A run's snapshot now carries its first
failed step and that step's error, step 'load' failed: … (run.step_failed), so the parent's
step reads run … failed: step 'load' failed: ….
Refusals that name what uses a thing agree with how many there are. Removing a connection or a
schema something still names reads … while it is named by pipeline nightly or … by pipelines a, b, in the API and in the browser, instead of while pipeline nightly name it. schema.in_use
carries the clause as a new users param beside pipelines and count. A header secret with a
control character reads no HTTP header can carry … and one was found in api_key.
An endpoint source says where it is called and how. The pipeline page, the editor's step
panel and the triggers screen's panel show an endpoint's methods and full URL with a copy button,
and how a caller authenticates: a bearer token, an HMAC signature, or public. New token (New secret on a signed source) asks first, then shows the new credential once with a curl line that
calls the endpoint with it. The triggers screen used to replace a token without asking.
A run says how it started, and whether its reply reached anybody. The run page reads by admin, from the sample, by admin, with an input given, or by a call to ask. A reply's box says sent
or not sent, and its panel says sent to the caller or recorded, not sent. The Run… dialog on
a pipeline with a source says it starts from the source's sample and that nobody receives the reply.
AttemptOut gains via, how a source's input arrived (request, poll, hold, hand, or
sample for a hand run that gave no input).
A step that runs no block says what it is. The editor's canvas and step panel name a reply
reply, as the run graph does, rather than no block, and the pipeline page draws no Blocks panel
for a pipeline whose steps run none.
The navigation rail resizes again. Drag its right edge between 152px and 320px, or focus the
edge and use the arrow keys, Home and End; the width is remembered in each browser, and a
double-click on the edge gives back the 168px it opens at. Cmd/Ctrl+B and Show navigation still
hide and show it whole.
dirigent-contrib moves with each release. It relocks at the tagged commit and takes the same
version as dirigent-dhis2 and dirigent-integration, and the integration make test that is run
before a release is called done covers it.
v0.29.0
First release of dirigent-fhir, the FHIR pack for dirigent. Requires dirigent 0.29.0.
- A
fhirconnection kind: base URL, no auth, bearer or basic,timeout,request_deadline(10m) andmax_response(64MB). Credentials go only to the base URL's origin, redirects included. Its check reads/metadataand reports the FHIR version. - Blocks:
fhir.read,fhir.search(follows next links on the same origin, up tomax_pages),fhir.create(conditional withif_none_exist),fhir.update(conditional onidentifier) andfhir.transaction(a transaction or batch Bundle, rewriting references between its resources tourn:uuid). - An
OperationOutcomeis classified into a step failure. - Built on the dhis2w-fhir engine's R4 models (
dhis2w-fhir-engine1.38.2); a resource without a model passes through as JSON.