#Job Bindings
A definition — an app spec or a workflow — 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 per-job share 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": "{{ 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 stored step still carrying the
former version / resolvedJobId keys still parses; those keys are dropped.
#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 definition | 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 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 per-job shares for the jobs its author owns and bound. Sharing the spec or the workflow is enough for those. Revoking that share denies on the next request, even though the binding is still stored — authorisation is re-evaluated live, never cached into a grant.
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 actively read-shared to it |
| 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 shares job J to tenant A; A binds J on its spec S and shares S to B — B does not read J. A's own jobs bound on S reach B exactly as before. The remedy is C's, not A's: C shares J to B directly, 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 shared it there too.
What may be pinned is unchanged — A may still bind J on S and run it itself. What narrowed is what a pin confers onward. And reads only: authoring another tenant's job is never conferred by any binding.
#What a share 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 a standing share 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
The response is the row as the write left it. A value that is not a uuid 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
job_bindings is also accepted on the config and integration PUT bodies, where
it replaces the map whole.
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
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 behaves differently: an omitted or empty env keeps the
tenant's stored overlay, and a non-empty one is merged over the workflow
definition's own config.env defaults — so a PUT carrying only job_bindings
never disturbs the env, and one carrying env adds to the defaults rather than
replacing them.
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, un-shared — is the 404 above.
Both checks run before the write, so a refused activation stores nothing and a refused config write leaves the previously 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, using the same two error shapes. So an activation the gate accepted can still break later — the owner archives the bound job, re-binds the default, withdraws the share — and the next run fails loudly with the same 400 or 404 the gate would have produced, rather than silently doing nothing or running a job that was never meant to run.
#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 an un-share.
Change any of them mid-flight and the next callback fails with
JOB_BINDING_UNRESOLVED; the run ends at terminalerroronce its retries are spent.
#Authoring bindings from the installer
HelmBridgeClient authors bindings in name@version terms and stores ids:
const ids = await client.resolveJobIds({
sendNotice: "send-notice@1.2.0",
archiveDoc: "archive-doc@2.0.0",
});
await client.bindSpecDefaultJobs(specId, ids);
await client.bindWorkflowDefaultJobs(workflowId, ids);
resolveJobIds lists GET /definition/jobs?name=&version= per distinct
reference, read as the owner tenant, and insists on exactly one exact match over
the whole set rather than guessing. job_bindings is also accepted on the
app-config and integration bodies, and
updateWorkflowConfig(workflowId, { env?, job_bindings? }) writes a workflow
activation's map.
#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
- Apps — app specs, activations, integrations
- Activate Shared Workflows & Apps — the activating tenant's side