Authorize

#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.sendNotice holds an inline body, that body runs. An inline body is part of the authored document and nothing can repoint it.
  • Otherwise sendNotice is 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, null unbinds it, and every unmentioned key stays as stored. null is 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_UNRESOLVEDJob <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:
    Job "<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.
    This fails the job step and is reported as that step's own error, so a surrounding sequence still runs its onError handler.
  • 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.unbound carries 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 terminal error once 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.
  • $job is not a directive. The $ namespace is closed; a $job key anywhere in a spec document is rejected at publish as an unknown $-key. A jobs map entry is an inline body, and a job step's key is either one of those or a slot.

#See also