#Job Bindings
A definition — an app spec, a workflow or a process — never names the job it
runs. It names a key. Which job that key runs is a separate, per-environment
decision called a binding: a { key: job id } map stored at three levels —
the definition's own default, a tenant's activation of it, and one integration
under that activation.
This is what makes a definition portable. The same published spec runs in your sandbox, in staging, and in a consumer's tenant with a different job behind the same key in each — no republish, no fork, and no name that has to mean the same thing everywhere.
It is also what makes those jobs readable. A bound id is not merely configuration: the platform authorises job reads against the bindings that pin them, so a consumer needs no manifest member for a job the definition's owner owns and bound. Access follows the pins.
#Keys and slots
A key is a slot when the definition supplies no body for it.
#An app-spec job step
{
"kind": "job",
"job": "sendNotice",
"input": { "$jsonata": "page.data.form" }
}
- If the spec's
jobs.sendNoticeholds an inline body, that body runs. An inline body is part of the authored document and nothing can repoint it. - Otherwise
sendNoticeis a slot, and the answer is whichever job the bindings put there.
A literal key must match /^[\w-]+$/ — letters, digits, underscore, hyphen. The
publish lint reports anything else as job_key_invalid.
#A workflow reference step
A workflow's jobs[] entry is inline when it carries actions, and a
reference otherwise. A reference step's name is the slot:
{
"stepId": "notify",
"name": "sendNotice",
"dependencies": ["fetch"],
"input": { "$jsonata": "outputs.fetch" }
}
The step pins no version and stamps no id.
#A process effect
A process effect with inline actions runs those, and nothing can repoint it.
An effect that names a kind instead is a slot named effect_<kind>:
"effects": {
"notify": { "kind": "notify", "input": { "task": { "$jsonata": "$anchor.id" } } }
}
Here the slot is effect_notify. A process has two binding levels — its
definition's default and the tenant's activation — and no integration level.
#Dynamic keys
An app-spec job step's job may be a smart value, resolved against the render
context and the accumulating step outputs at run time. The resolved string
then goes through the inline map and the binding levels exactly as a literal key
would. A key is whatever the step evaluates to — a bindings write draws no
distinction between a key that appears literally in the document and one a step
only ever computes. Neither is "declared" in any sense a write cares about; both
are accepted the same way, and nothing is flagged.
#The three levels
Every level stores the same shape — { "<key>": "<job uuid>" }:
| Level | Where it lives | Who writes it |
|---|---|---|
| default | on the spec / workflow / process | the definition owner |
| activation | on a tenant's configuration of it | the activating tenant |
| integration | on one integration under that config | the activating tenant |
Default-level bindings are owner data, not authored document. They live beside the definition body, never inside it, so the same published document carries different bindings per environment and a republish never overwrites a deliberate pin.
Resolution order is integration → activation → default: an integration's binding wins over the tenant's activation config, which wins over whatever the definition ships as its default. Workflows and processes have no integration level, so their order is activation → default.
A map is written two ways:
- PUT replaces it whole — the declarative form the installer uses, where the
document is the desired state. An omitted map on a partial-update body leaves
the stored one alone; an explicitly empty
{}is a real write that withdraws every binding. - PATCH changes single keys — a value binds the key,
nullunbinds it, and every unmentioned key stays as stored.nullis what makes unbinding expressible without restating the map.
A PATCH is additionally conditional on the map still being the one it read, so two tools changing different keys never overwrite each other: the loser receives a 409 and re-reads. A PATCH that changes nothing writes nothing and answers the current view.
#What a binding gives a consumer
A consumer needs no job members for the jobs its author owns and bound. A definition reaches a consumer as a member of a manifest the consumer has accepted (see Share with Another Tenant), and that is enough for the jobs in its default bindings. Revoking the publication, or the consumer declining it, denies on the next request even though the binding is still stored — authorisation is re-evaluated live, never cached into a grant.
A job the consumer binds itself needs a member. An activation- or
integration-level binding names a job the consumer picked, not one the author
pinned. The consumer can bind it only if it owns the job or the job is a
job_definition member of a manifest it has accepted — addressed by id, or by
resource_name plus resource_version:
{
"resource_type": "job_definition",
"resource_name": "send-notice",
"resource_version": "1.2.0"
}
Without that member the bindings write is refused. A workflow write fails the
activation gate with the 404 below
(Job <id> does not exist or is not readable by this caller.). An app write
fails with a 422 whose message names the remedy — share the job to this
tenant or bind one it owns.
Derived access is a runtime and administrative convenience, never an implicit grant to a policy-scoped credential:
| Caller | What it reaches through bindings |
|---|---|
| App token (activation claim) | its own activation, that activation's integrations, and the spec it activates — nothing else |
| Workflow token (workflow claim) | that workflow, and its own tenant's configuration of it — nothing else |
| Member (no claim, no policy) | every definition and activation of its own tenant, plus specs and workflows shared to it through an accepted manifest |
| API token bearing a policy, no claim | nothing through bindings — it reads jobs only through its policy's explicit grants |
A token carrying both an activation and a workflow claim is judged as the activation's: it is the narrower of the two, and a credential minted for an activation runs that activation.
#A shared definition carries only the author's own jobs
C offers job J to tenant A in a manifest A accepts; A binds J on its spec S and offers S to B — B does not read J. A's own jobs bound on S reach B as usual. The remedy is C's, not A's: C lists J in a manifest B accepts, and the same stored binding resolves for B with nothing else changed.
In one line: a consumer of a shared definition reaches the jobs its author owns and bound; a job the author borrowed reaches that consumer only if the job's owner offered it there too.
What may be pinned is wider than what a pin confers onward: A may bind J on S and run it itself. And reads only: authoring another tenant's job is never conferred by any binding.
#What sharing a definition discloses
Sharing an app spec or a workflow is not only a licence to run it: the consumer reads the bodies of the author's own jobs that definition binds. That is what makes a binding work at run time, and it means a job bound on a shared definition is disclosed to every tenant that definition is shared with.
Publish-time auto-fill widens that set without anyone deciding to. Every workflow publish fills each unbound reference name from the author's own latest active job of that name, so a republish can add a job to what an accepted manifest already discloses. Apps never auto-bind, so an app spec discloses only what someone bound on purpose.
Before sharing a definition, read its default bindings and treat the job bodies behind them as visible to the consumer.
#When a binding fails
A bound job runs only while its status is active or deprecated; anything
else is refused.
404 JOB_BINDING_UNRESOLVED — Job <id> is <status>. for a draft,
inactive or archived job, and
Job <id> does not exist or is not readable by this caller. when the row is
gone or invisible. Missing and invisible share one refusal on purpose: telling
them apart would leak existence across tenants.
The pin rule is deliberately wider than the run rule — only archived is
refused a pin — so an owner may bind a slot to a job still in draft and have
it start resolving the moment that job goes active, with no re-binding step.
The wider rule decides what may be pinned; the narrower one decides what may
run.
400 JOB_BINDING_UNRESOLVED when nothing binds the key at any level. The
message names the fix, not just the fact:
- App step:
This fails theJob "<key>" is not bound at any level for app config <appConfigId> (integration <integrationId or "none"> → activation → spec default). Bind it with PUT /apps/<specId>/config { job_bindings }, on the integration, or as a default on the spec.jobstep and is reported as that step's own error, so a surrounding sequence still runs itsonErrorhandler. - Workflow reference step:
Job "<name>" is not bound at any level for workflow <workflowId> in tenant <tenantId> (activation → definition default). Bind it with PUT /workflows/<workflowId>/config { job_bindings } or as a default on the workflow.details.unboundcarries every unbound name, sorted; the message names only the first, so one refusal reads as one instruction.
A bound-but-unfetchable job never raises the 400 — it raises the 404 instead, since "bind something" is not the fix when something is already bound.
#Writing bindings
#Default level — the definition owner
Owner only, guarded by app_spec:update / workflow_definition:update.
PUT/definition/specs/{id}/default-job-bindingsReplace an app spec's default bindingsAPI docs ↗Try it
PATCH/definition/workflows/{id}/default-job-bindingsChange single keys of a workflow's default bindingsAPI docs ↗Try it
A default-level value is a job id or name@version (send-notice@1.2.0); see
Binding by name and version. The response is the
row as the write left it. A value that is neither is a 422; so is a job this
tenant may not pin — the message names the remedy, share the job to this tenant
or bind one it owns.
GET /definition/specs/{id} and GET /definition/workflows/{id} return
default_job_bindings alongside default_job_bindings_detail — the map
resolved against the job heads this caller can read:
{
"sendNotice": {
"id": "3f0c1a8e-1d54-4b0a-9a7c-2b2f2c9e6a11",
"name": "send-notice",
"version": "1.2.0",
"status": "active",
"level": "default"
}
}
A key whose winning job the caller cannot read is omitted — a read never reports a job through a level the caller could not reach directly.
What a publish reports. Workflow create and update both carry two required
auto-fill fields, auto_bound[] and unresolved_names[]; "nothing to fill" is
[], never an absent field. Auto-fill takes the owner's own latest active job
of that exact name, matched case-sensitively and newest by version. It fills
gaps and never overwrites an existing binding, so a deliberate pin survives a
republish, and a name with no active own job is reported rather than guessed at.
Auto-fill never blocks the publish — a refused fill comes back in
unresolved_names, and the remedy is a default-level write that drops the stale
pin. Apps never auto-bind.
#Activation and integration level — apps
PATCH/apps/{specId}/config/job-bindingsChange single keys of this tenant's activation bindingsAPI docs ↗Try it
PATCH/apps/{specId}/integrations/{integrationId}/job-bindingsOverride one key for a single integrationAPI docs ↗Try it
The activation level takes a job id or name@version; the integration level
takes a job id. job_bindings is also accepted on the config and integration
PUT bodies, where it replaces the map whole, with the same values.
The config read reports the activation over the default — an activation is not
integration-scoped, so it shows what every integration starts from. An
integration read reports all three levels, and each entry's level is what
tells you whether an integration is overriding the activation, or the activation
the default.
Apps have no activation-time gate. A config or integration write is never
checked against what the spec's job steps declare, because a step's key may be
computed at run time and is never fully enumerable ahead of it. An unbound key
is caught the one place it can be — at run time, when the step evaluates its
key.
#Activation level — workflows
PATCH/workflows/{id}/config/job-bindingsChange single keys of a workflow activation's bindingsAPI docs ↗Try it
A workflow activation's bindings are job ids. On PUT /workflows/{id}/config,
job_bindings is replaced whole when present and left alone when omitted,
so bindings can be written without restating env. env is merged key by key
over the tenant's stored env: keys you send are set, a key sent as null is
unset, and keys you do not mention keep their stored value. A PUT carrying only
job_bindings therefore never disturbs the env. The definition's own
config.env is merged underneath when the env is read and when a run starts,
and is never stored — see Configuration & Config Schemas.
On the read, job_bindings is the tenant's own map — what a write would
replace — and job_bindings_detail is what each slot actually resolves to. The
PUT answers with that same view, computed from the map it stored, so you never
have to re-read to learn what your own write settled on.
The activation gate. Unlike an app, a workflow's reference steps are named
statically, so every slot it declares can be enumerated ahead of time. That is
what makes a gate possible: POST /workflows/{id}/activate,
PUT /workflows/{id}/config (whenever the body carries job_bindings), and the
bindings PATCH all refuse to record a state that cannot run, evaluated over the
map as it will stand after the write:
- Every declared reference name must resolve through activation → default. A name nothing fills is the 400 above.
- Every id a declared name resolves to must be fetchable by the activating
caller right now. An id that is bound but not runnable — archived, still
draft, not shared to this tenant — is the 404 above.
Both checks run before the write, so a refused activation stores nothing and a refused config write leaves the stored map untouched — including a write whose only effect would have been to remove the one binding still filling a name.
The gate is at the activation boundary, never the definition boundary. A default-level write is not checked against any tenant's activation: an owner may archive, re-bind or withdraw a default binding freely, even one every existing activation depends on. An activation is therefore a promise that holds at the moment it is made, not a guarantee the owner cannot later break.
What keeps it honest afterwards is that dispatch re-resolves every reference at
the run's own credentials on every run, unconditionally. So an activation the
gate accepted can still break later — the owner archives the bound job, re-binds
the default, revokes the publication — and the next run ends in error rather
than silently doing nothing or running a job that was never meant to run.
#Processes — both levels
The owner writes the default level on the process definition with PUT /
PATCH /definition/processes/{id}/default-job-bindings, the same body as a
workflow's. The activating tenant writes the activation level with the
job_bindings of PUT /workflows/processes/{name}/activation (whole map) or
changes single slots:
PATCH/workflows/processes/{name}/activation/job-bindingsChange single effect slots of a process activationAPI docs ↗Try it
Both levels take a job id or name@version. The activation write is gated like
a workflow's: every effect slot must resolve through activation → default, and a
slot nothing fills is a 400 JOB_BINDING_UNRESOLVED
(Effect slot "<process>: effect_<kind>" is not bound at any level …, with
details.slot and every unbound slot in details.slots). An effect's job takes
its credentials from its input and the process's auth profiles, so a job that
holds a {{ secrets.* }} reference is refused at either level (422,
details.code: "SECRET_REF_IN_EFFECT_JOB", details.paths naming where the job
holds it).
#Versions
A version lives on the job and is author-incremented: publishing a new version
is POST /definition/jobs with the same name and a bumped version, which
creates a new job with a new id.
Nothing moves to it by itself. Re-binding a key at a level moves everyone who inherits that level — repointing a spec's default binding moves every tenant that has not overridden that key. An activation or integration override is never moved automatically; the tenant that set it owns it until it changes it.
#In-flight runs
A run holds the id it dispatched with, not a copy of the job body, and every later fetch of that id is a live authorisation check. The two sides differ:
- The owner's own runs survive an archive. The callback path drops the runnable-status gate but not visibility, and an owner always sees its own job.
- A consumer's run does not survive a re-bind, an archive or a revoked
publication. Change any of them mid-flight and the next callback fails with
JOB_BINDING_UNRESOLVED; the run ends at terminalerroronce its retries are spent.
#Binding by name and version
A binding stores an id. The default level (specs, workflows and processes), an
app's activation and a process's activation also accept send-notice@1.2.0: the
write resolves it — your own job of that name and version first, else one shared
with you — and stores its id. An integration's and a workflow activation's
bindings take the id only: look it up with
GET /definition/jobs?name=send-notice&version=1.2.0 and write that id.
#Bound jobs and HTTP auth profiles
A bound job's http or httpCallback action may name an auth profile —
"auth": "partner". The name is resolved against the root auth map of the
definition that runs the job (the app spec or the workflow), and the profile
is evaluated with that definition's config — config.app.* in an app,
config.env.* in a workflow, each tenant's own values. Nothing in the job
itself declares profiles.
Nothing can check the name before the job runs: a job has no root auth map of
its own, and binding a job never reads its body. A definition that binds a job
naming a profile it does not declare fails at run time:
auth profile "partner" is not declared in the definition's root `auth` map.In a workflow the run's error carriesdetails.code: "HTTP_AUTH_PROFILE_UNKNOWN"anddetails.declared(the profiles the definition does declare); in an app thejobstep reports it as anERRORnotification.auth profile "partner" cannot be used: its secrets are not set.— the profile is declared, but the secret reference its config key holds has no value yet. In a workflow the run's error carriesdetails.code: "HTTP_AUTH_SECRET_MISSING"anddetails.missing_secrets.
So a library job that names a profile is a contract on every definition that
binds it: each must declare a profile of that name in its root auth map. See
HTTP Auth & External Data and
Configuration & Config Schemas.
#Two rules worth knowing
- Spec policies may not grant
job_definition. Reading a job follows the bindings that pin it, so an author-written grant would only widen access beyond the jobs the app actually runs. $jobis not a directive. The$namespace is closed; a$jobkey anywhere in a spec document is rejected at publish as an unknown$-key. Ajobsmap entry is an inline body, and ajobstep's key is either one of those or a slot.
#See also
- Workflows — authoring jobs and workflows, sharing, day-2 ops
- Configuration & Config Schemas — the env and config written beside the bindings
- Share with Another Tenant — manifests, and which jobs need a member
- Apps — app specs, activations, integrations
- Accept & Activate — the activating tenant's side