Application checks
Prove the app does its job after it deploys — a dibbla-checks.yaml your deployment ships, run on demand from the CLI, with typed results your pipeline can gate on.
A deployment can be green and the app still broken — the container answers to
Kubernetes, the homepage 500s for the user, and nothing in the replica count
ever notices. Application checks close that gap: you describe, in a file next
to the Dockerfile, what working means for your app, and you run it
against the running deployment from the CLI.
A check does not ask Kubernetes whether the container is alive. It does what a user does: it opens the public route, follows the flow, reads the answer. If “the deploy completed” and “the user can complete the task” have ever diverged on you, this is the instrument for the second half.
Nothing here is on until you turn it on, and there are two switches, not one. Both ship off, for every app and every organization — including Dibbla’s own.
- The organization capability. Enabled organization by organization rather
than switched on for everyone at once. While it is off, every checks command
answers
404withAPPLICATION_CHECKS_DISABLED— that is the rollout, not a mistake on your side and not something a different command will fix. - The app itself. Off by default even inside an enabled organization. You
turn it on per app with
dibbla apps checks enable, and shipping adibbla-checks.yamldoes not do it for you.
If you added the file and nothing happens, it is almost always one of these two, in that order.
The file
Checks live in dibbla-checks.yaml at the root of your app, next to
dibbla.yaml and the Dockerfile. Every check needs an id, a name, a
description and a kind. The smallest useful one:
version: 1
checks:
- id: home-page
name: Home page is usable
description: >
The home page is the first thing a customer sees. If this fails the app is
down for everyone, whatever the other checks say.
kind: http_sequence
schedule: nightly
steps:
- request: {method: GET, route: public, path: /}
expect:
status: 200
body_contains: "Welcome"
Deploy as usual. A successful deployment promotes the file into an immutable,
content-addressed check source snapshot — the checks that run are exactly
the checks you shipped with that revision, and a failed deploy never replaces
the ones already in flight. dibbla apps get <alias> (and the console) show
the active snapshot’s revision.
route is always a logical route from your deployment — for a plain web app,
public is the public route your alias serves. It is never a host or URL you
type in: a definition cannot point a check at an arbitrary server, and paths
are relative. Redirects are re-validated at every hop and cannot leave the
app’s authorised host set.
The kind that runs today
One kind executes: http_sequence.
| Kind | What it proves | How it runs |
|---|---|---|
http_sequence | The app answers over HTTP the way a client expects | A sequence of GET/HEAD/OPTIONS requests with expectations on status, body and JSON fields |
browser_journey, semantic and composite are defined in the file format and
accepted by the validator, and that is the trap: a file containing one deploys
green and stores the definition, so nothing at deploy time tells you it will not
run.
What happens instead is quieter than a failure. The check is dispatched, it
reports, and its outcome is indeterminate with the code
CHECK_KIND_UNSUPPORTED — neither pass nor fail. Nothing turns red. A
run history full of indeterminate rows is the easiest thing on this page to
misread as “all clear”, so it is worth knowing the code by sight: if you see
CHECK_KIND_UNSUPPORTED, the check did not run and you have no answer about
your app.
Do not ship one expecting a result. They are documented here when they execute, not when they parse.
The browser journey is deliberately a YAML step language, not JavaScript: no
repository scripts, no evaluate, no package installs, no filesystem. The
runner is platform-owned, so a check definition can’t become an arbitrary code
execution ticket — and can’t rot when your app’s node_modules change. Steps
address things by name, never by what they look like: route is a logical
route, and click names a control by its lowercase id — so a journey survives
a copy change and breaks loudly when a control disappears.
A journey that can change something must say who it acts as
navigate and assert_text only look. click and fill can change state, and
the moment a journey contains either, it must name an identity_grant — an
isolated fixture identity to act as, never a real user’s:
- id: signup-works
name: A visitor can complete signup
description: >
Signup is how every new customer arrives. If this fails nobody new can
reach the product, and existing users see nothing wrong.
kind: browser_journey
identity_grant: disposable-fixture
steps:
- navigate: {route: public, path: /signup}
- fill: {control: email-field, value: {secret_ref: fixture-email}}
- click: {control: submit-signup}
- assert_text: {text: "Check your inbox"}
Leave it out and the file is rejected — “click/fill browser actions require identity_grant for an isolated fixture”. This is the single most common reason a first draft does not deploy.
The field is required and it changes how the check is classified, but there is no way to create the grant it names yet: the platform has no grant provisioning API, and nothing resolves the name at run time. That is on top of the kind itself not executing — see The kind that runs today, which is the one place this page says what runs. The shape above is documented so that the file you write now stays valid when journeys and grants arrive, not because you can use it yet.
Values are objects, not strings: {literal: "..."} or {secret_ref: <id>}.
Filling a control whose id contains password, token, secret, cookie,
authorization or api-key with a literal is refused outright — those must be
a secret_ref, and the value never appears in an API response, event or log.
The platform derives a classification from all of this and shows it in
dibbla apps checks list: read_only_deterministic for an http_sequence or a
look-only journey, isolated_fixture_mutation once a journey has an
identity_grant, a secret_ref or any click/fill, and semantic for the
judged kind. It is derived, never declared — you cannot label a mutating journey
read-only.
A composite names checks from the same file and has exactly one reducer:
- id: signup-journey
name: Signup works end to end
description: >
Ties the pieces together: a green home page and a green signup form can
still add up to a broken path from landing to account.
kind: composite
checks: [home-page, signup-works]
reducer: all_required
The scheduling fields
schedule is required on every check and nightly is its only accepted value —
a product abstraction, so there is no raw Cron to get wrong. failure_threshold
(default 2) and cooldown (default 30m) govern how repeated failures
notify. Write all three: they are validated at deploy and the file is rejected
without schedule.
Checks do not currently run on a cadence. Writing schedule: nightly makes
the file valid; it does not yet cause anything to execute overnight. Every
result you can obtain today comes from Run now, below.
run_deadline (default 2m) bounds a run. A hung check records error; it
never hangs forever, and the whole-run deadline and each step timeout are
reported as separate codes — one ambiguous “timeout” cannot mean both.
Turning checks on
Checks never run just because a file exists. This is the second of the two switches at the top of this page, and it is off on every app until someone turns it on — explicit, per app, and it needs the owner or admin role:
$dibbla apps checks enable myapp --yes ✓ application checks enabled for myapp (settings version 3)
# definitions and history are preserved $dibbla apps checks disable myapp --yes ✓ application checks disabled for myapp (settings version 4)
Disabling preserves definitions and history, which stay readable. Enabling requires the app to actually ship checks — you cannot switch on an empty instrument.
Run now
This is how a check runs. You ask for it, and it executes against the running deployment:
$dibbla apps checks run myapp --check home-page ⏳ running 1 check(s) — execution acx_8f31 (running) ✅ pass — execution acx_8f31 (1 check(s)) took: 9s
# exit 8 = the app failed the assertion; exit 0 = pass $dibbla apps checks run myapp --check smoke --json | jq -e '.exit_code == 0'
A failing check is not a command error — exit 8 means the check found
something, which is the check doing its job. The full ladder, the --async
and --follow forms and the NDJSON contract are in
the CLI reference.
Reading results
Every run leaves a typed result — outcome, a stable machine code, and a
bounded summary — not a wall of text:
$dibbla apps checks history myapp --since 24h 🕓 check run history for myapp STARTED CHECK OUTCOME CODE SUMMARY 2026-08-25 14:02 home-page pass 200 OK, citation present 2026-08-25 11:47 home-page fail ASSERTION_FAILED Expected citations, received none
Codes like ASSERTION_FAILED are stable identifiers — a dashboard or a grep
can key on them. Summaries are bounded and redacted by the platform: a result
never carries raw response bodies, logs, prompts or secrets.
Notifications
Check state changes fire typed events you can route like any other — see the notifications guide:
| Event | When it fires |
|---|---|
application.check.failed | A check transitions into fail after its threshold |
application.check.error | The check instrument itself is in error — the tool failed, not the app |
application.check.indeterminate | A valid run hit an evidence or budget ceiling |
application.check.recovered | A previously failed check returns to pass |
In steady state, checks are silent. Threshold, cooldown and deduplication mean a check that flaps all night is one notification and one recovery — not two hundred.
Apps behind a login
A check can test an app that has require_login or an access policy. You do
not have to make the app public to monitor it.
The check still goes in through the app’s public route and the Dibbla proxy. For each run the platform gives it a short-lived credential. The proxy recognises that credential and lets the request through as a machine identity. It does not act as you, and it does not act as any other person. Your app sees these headers:
| Header | Value |
|---|---|
X-User-Kind | application-check (sent only for checks) |
X-User-ID | dibbla-application-check |
X-User-Email | [email protected] |
X-User-Name | Dibbla application check |
X-User-Org | your app’s organisation |
X-User-Org-Role | viewer |
X-User-Check-Run | the id of the check run making the request |
These are the same X-User-* headers a signed-in person gets,
so a page that only needs someone signed in answers the check exactly as it
answers a user. If your app looks users up in its own database, the check
identity is not in it. Decide what the app should show that identity. The
usual answer is to let X-User-Kind: application-check read what a viewer can
read.
The credential cannot be used anywhere else:
- One app. It opens only the app whose check run it was issued for.
- One run. It stops working as soon as the run finishes or is cancelled, and never lasts longer than the run’s deadline.
- Read-only. The proxy accepts it for
GET,HEADandOPTIONSonly, which are the methods a check may use. A write is refused before it reaches your app. - Never seen by your app. The proxy removes the credential before
forwarding the request. Visitors cannot pose as the check, because every
inbound
X-User-*header is stripped at the edge. - Never sent to another host. If your app redirects the check to another hostname, that request goes without the credential.
If the proxy refuses a check’s credential, the step fails with a 401 or
503 JSON answer and the check fails. It never gets the login page, so a
login screen can no longer make a check look green.
Not the same as a manifest healthcheck
healthcheck: in dibbla.yaml is the
Kubernetes liveness/readiness probe: it asks the container to prove its
process is up, on the cluster’s terms, and a failing probe restarts the pod.
Application checks ask the app to prove a user’s task works, on the public
route, and never restart anything. They answer different questions on purpose
— a pod can be perfectly live and serve error pages.
What a check can never do
The safety model is part of the contract:
- No arbitrary URLs. Routes resolve from your deployment; paths are relative; redirects cannot escape the app’s host set.
- No secrets in the file. A check names an app-scoped secret; the control plane resolves it for that run only, and the value never appears in an API response, event or log. This is enforced, not advised: a literal in a credential-looking field is rejected at load.
- No arbitrary code. Read-only HTTP methods; a bounded browser DSL; one
structured-output judgement — never a shell, never
evaluate. - No silent best-effort. Unknown keys are rejected, so a misspelled field
cannot quietly weaken a check into a no-op — and so is a missing required
one: a check without a
descriptionis refused at deploy, before the build, withAPPLICATION_CHECKS_DESCRIPTION_REQUIREDnaming it.
Have an agent draft it
The file is declarative and small enough that a code agent can propose a first draft from your app’s routes — you review what “working” means before it ships:
Have a code agent draft dibbla-checks.yaml from the app's real routes.
Look at this app's routes and user flows and draft a dibbla-checks.yaml at the repo root: version 1, two or three checks. Match the documented shape exactly: the file is `version: 1` then a `checks:` list where each check has `id`, `name`, `description`, `kind` (always http_sequence — it is the only kind the platform executes; do not write browser_journey, semantic or composite, which validate but return indeterminate/CHECK_KIND_UNSUPPORTED) and `schedule: nightly` — never raw cron. `description` is REQUIRED on every check, 1-1000 characters, and a file missing it is rejected at deploy before the build. Write it for a human, not for the runner: say why the check exists and what it means that it failed, never what it technically does — the steps already say that. "The signup form is how every new customer arrives; if this fails nobody can sign up" is the right shape; "checks that POST /signup returns 200" is not. Requests are read-only: `method` is GET, HEAD or OPTIONS only, on a logical `route` name from the manifest — public for a plain web app — with a relative `path`, never a URL or host. An http_sequence check carries `steps:` whose entries are `request: {method, route, path}` plus `expect: {status, body_contains, json_has}`. Do not write the other kinds at all: a `browser_journey`, `composite` or `semantic` check in this file will deploy green and then return `indeterminate` forever, which is worse than having no check, because it looks like coverage. Show me the draft before writing the file. See also
- Check before you deploy — where the checks fit in the
deploy flow, and the agent tools that run them
(
platform_app_checkswithview: definitionsorhistory,platform_app_runwithkind: checks, andplatform_app_checks_set_enabled). dibbla apps checks— every command, the exit-code ladder, and the JSON/NDJSON contract.- Notifications — routing check events to Slack, email, Discourse or a webhook.
- Logs — when a check fails, the log stream is where the reason usually is.
- The manifest reference — where
healthcheck:lives, and how it differs.