<!-- © TALOS Group Pty Ltd, used under licence by TALOS Defence Pty Ltd. -->
<!-- depth: 3 -->
<!-- Body written by slices D-15 and D-14, 23 October 2026. tests/docs_contract.rs reads the state, outcome and descriptor names on this page. -->

# Releases and jobs

Releases and jobs. A release is a version of your application that Hangar runs for each of your customers. Each job uses your prepaid credit.

A vendor builds its application once, as a package. Hangar runs a signed, accepted release of the package for each tenant (each customer) of the vendor. Every job ends with the artefacts that the application wrote, and a return. The return is a job certificate. It shows which release ran, the envelope, the pool and every attempt. Anyone can verify the return offline.

TALOS Warrant is the part of Hangar that decides what a job may do. Before a job starts, TALOS Warrant compiles the envelope: the set of limits that the job cannot leave. The envelope comes from what the release asks for and what your policies allow. During the job, TALOS Warrant enforces the envelope. After the job, TALOS Warrant signs the return.

The route, start to end:

1. You make a release key on your own machine and register it.
2. You push your image and create a package version from `hangar_package.json`.
3. You sign the release and submit it.
4. TALOS Defence runs the acceptance checks, including a self-test job of the release, and signs its acceptance.
5. You move a channel, such as `stable`, to the release.
6. You start jobs of the release for your tenants, with the Hangar app, the `hangar` command line or the Python package `gethangar`.
7. Each job ends with its artefacts and a signed return.

The reference package `hangar_patch_agent` in `examples/patch-agent` follows this route from start to end.

## 1. The application contract

Your application runs as the entrypoint of the image, in a container on a fresh instance for each attempt. The application reads its inputs and its configuration from fixed paths, and writes its outputs to a fixed folder. The application reports its result with its exit code.

| Path | What is there |
|---|---|
| `/hangar/inputs/<name>` | The exact bytes of each artefact input, read-only, under the input's name. |
| `/hangar/inputs/values.json` | The inputs that are not artefacts, as compact JSON with the keys sorted. |
| `/hangar/config/config.json` | The configuration for the pool and tenant of the job, with the defaults applied, as compact JSON with the keys sorted. Read-only. |
| `/hangar/outputs/` | Empty and writable. Write each declared output here under its declared name. |

The environment:

| Variable | What it holds |
|---|---|
| `HANGAR_RUN_ID` | The id of the job. |
| `HANGAR_ATTEMPT` | The attempt number, from 1. |
| `HANGAR_RELEASE` | The release id, as `sha384:` and 96 hexadecimal digits. |
| `HANGAR_TENANT` | The tenant id, for a job that has a tenant. |
| `HANGAR_INPUTS` | The inputs folder, `/hangar/inputs`. |
| `HANGAR_CONFIG` | The configuration folder, `/hangar/config`. |
| `HANGAR_OUTPUTS` | The outputs folder, `/hangar/outputs`. |

Read the folders through the three variables. A local runner, such as the reference package's `run_local.py`, sets them to temporary folders.

Exit code 0 means that the application finished. Any other code fails the attempt. Hangar still seals and keeps the declared outputs, so you can see what the application wrote.

Hangar collects only the outputs that the package declares. Hangar lists the output folder without following links. Hangar records any other file name, link or folder as skipped, and does not read the skipped item. Hangar still seals and keeps an output that is larger than its `max_bytes`, and the output fails its descriptor.

The application connects to the network only through the gateway, and only to the destinations in the envelope. The containers of the job trust the certificate authority of the session, which vouches for those destinations only. `SSL_CERT_FILE` gives the path of the certificate authority file.

## 2. The package manifest

`hangar_package.json` describes the package. The file is JSON, in the format `hangar-package-v1`. Hangar refuses a field that Hangar does not know. A misspelt field is an error, and Hangar never drops a setting without a message. The schema `docs/api/package-manifest.schema.json` describes the file for editors. The parser in Hangar is the final authority.

| Field | What it holds |
|---|---|
| `format` | `hangar-package-v1`. |
| `name` | The package name: a lower-case letter, then 1 to 62 lower-case letters, digits and underscores. Unique in your organisation. |
| `version` | `MAJOR.MINOR.PATCH`, digits only. A version never changes once created. |
| `description` | One line about the package. |
| `image` | `digest`, the `sha256:` digest of the image, and `platform`, `linux/arm64` or `linux/amd64`. |
| `entrypoint` | The program and its first arguments, as a list. |
| `arguments` | More arguments, as a list. |
| `resources` | `vcpus`, `memory_mib`, `disk_gib`, `gpu` (`none` or `h100`) and `max_minutes`, the longest one attempt runs. |
| `inputs` | The name and type descriptor of each input, with `required`. |
| `outputs` | The name and type descriptor of each output, with `required`. Every output is an artefact. |
| `config` | Each configuration key, with the type descriptor, the `default` and `site_specific`. |
| `envelope` | The envelope that the package asks for. See below. |
| `sbom` | The `sha384:` digest of the software bill of materials (SBOM) of the image. You upload the SBOM to Hangar. |
| `runtime` | The Hangar runtime versions that the package supports: `minimum`, and `below`, the first version that the package does not support. |
| `determinism` | `deterministic`: the same inputs and configuration give the same output bytes. `envelope`: the outputs stay within the declared contract, and their bytes may differ. |
| `self_test` | The inputs of the acceptance self-test, and the outputs that the self-test must give. |

### The seven type descriptors

Inputs, outputs and configuration keys use a small set of descriptors that Hangar defines. The set is closed. A descriptor is one of these seven.

| Descriptor | Fields | A value matches when |
|---|---|---|
| `string` | `max_length`; for a configuration key, `destination: true` | it is a string of at most `max_length` characters. With `destination: true`, it is the name of a destination the package declares. |
| `integer` | `minimum`, `maximum` | it is a whole number in the range. `true` and `false` are not numbers. |
| `boolean` | none | it is `true` or `false`. |
| `enum` | `values` | it is one of the listed strings. |
| `object` | `fields`, each a descriptor with `required` | it has every required field, and no field the object does not name. |
| `list` | `items` (a descriptor), `max_items` | it is a list of at most `max_items` items, each matching `items`. |
| `artefact` | `media_type`, `max_bytes` | it is an uploaded object of at most `max_bytes`, named by its `sha384:` digest. |

An artefact is an input or an output, never a field of an object, a list item or a configuration key. Descriptors nest at most six deep.

Hangar refuses a job at submission with `input_contract` when an input does not match its descriptor. An output that does not match its descriptor fails the attempt with the reason `output_contract`. Hangar keeps the artefact under your retention, so you can see what the application wrote.

Artefacts travel as digests. The bytes pass through the upload routes into the storage of your organisation in Sydney. The envelope and the return list each artefact by digest, size and media type. The envelope and the return never contain the bytes of an artefact or any value.

### The envelope the package asks for

The package asks for the permissions that it needs. The package never gives those permissions to itself. TALOS Warrant compiles the envelope from the request and your policies (Section 7).

| Field | What it asks for |
|---|---|
| `destinations` | Each destination by its exact DNS name, with `purpose`, one sentence a person reads, and `required`. |
| `data_scopes` | `run_inputs`, the artefacts given as the job's inputs, or none. |
| `secrets` | Each secret by `name`, bound to one declared `destination`, with the `header` the gateway sets and an optional `scheme`, and `required`. |
| `companions` | Each companion container by `name` and `image` digest. A companion runs in the instance of the attempt and uses the budget of the attempt. |
| `model_routes` | Each model route by `name`, with its primary `destination` and an optional `fallback`, which is itself a declared destination. |
| `person_present` | The steps that wait for a named person: `action` (`destination_first_use`, `credential_use` or `agent_action`), the `target`, and the `sentence` the approver reads. |
| `compute` | `vcpus`, `memory_mib` and `max_minutes`, equal to `resources`. A job can narrow these values. The package never asks for more than `resources` declares. |
| `failure` | `retries`, from 0 to 3, and `resume_from_checkpoint`. |

Hangar refuses a package version with `envelope_request_invalid` when the request binds a secret to an undeclared destination, gives a fallback that is not a declared destination, or has a malformed name. The error shows the item.

The CPU pool `hosted-cpu-syd` runs packages for `linux/arm64` with `gpu` `none`. You send the image with `hangar upload --image`, and the manifest refers to the image by its digest.

```sh
hangar upload --image ./patch-agent.oci.tar --name patch-agent:1.0.0
hangar release create
```

`hangar release create` reads `./hangar_package.json` (or the file that you name), sends the file byte for byte, and prints the package version with the manifest digest. The manifest digest is the domain-separated SHA-384 of the exact bytes, with the context `hangar-package-v1`. The command line computes the same digest on your machine.

## 3. Releases

A release pins one package version, by digest, under your signature. A release is a canonical CBOR document of the format `hangar-app-release-v1`:

| Field | What it holds |
|---|---|
| vendor | The chain and name of your organisation. |
| package, version | The package version that the release pins. |
| package manifest digest | The digest of `hangar_package.json` as you sent it. |
| image | The image digest and its platform. |
| command | The entrypoint and arguments. |
| resources | As the manifest declares them. |
| companions | Each companion by name and image digest. |
| SBOM digest | The software bill of materials. |
| envelope request | The envelope the package asks for, in full, and its digest. |
| configuration schema digest | The digest of the declared configuration keys. |
| runtime | The range of supported runtime versions. |
| determinism | `deterministic` or `envelope`. |
| sequence | Your release number for the package: 1, then one more for each release. |
| created at | When the release was drafted. |

The release id is the domain-separated SHA-384 of these bytes, with the context `hangar-app-release-v1`, written as `sha384:` and 96 hexadecimal digits. A release refers to an image only by digest, never by tag, and contains no configuration value. A changed image has a different digest, so it needs a different release with its own signatures. Pushing an image again under the same name changes no release, no channel and no job.

### Your release key

You sign your releases with your own release key: a hybrid key pair of ML-DSA-87 and ECDSA P-256. `hangar release keygen` makes the key pair on your machine and keeps the private half there, in a file of mode 0600. The private half never leaves your machine. TALOS Defence cannot make or use the private half.

```sh
hangar release keygen talos-release
hangar release register talos-release
```

An owner of your organisation registers the public half, signed in with a passkey (`POST /v1/vendor-keys`). The registration is a record on the chain of your organisation. A request key cannot register a release key.

### Signing and submitting

```sh
hangar release submit hangar_patch_agent@1.0.0 --key talos-release
```

The command line asks Hangar for a draft of the release (`POST /v1/packages/{name}/versions/{version}/release-draft`). Hangar builds the draft from the package version that Hangar holds, with the next sequence number. The command line checks that the draft matches `hangar_package.json` on your machine, signs the release id with your key, and submits the manifest and the signature (`POST /v1/releases`). Hangar accepts the submission when:

- the bytes are the canonical encoding of the manifest;
- every field equals the field in the package version that Hangar holds;
- the sequence is the next one, and the draft is at most 600 seconds old;
- the signature verifies with an unrevoked release key of your organisation.

A release has these states in order: `submitted`, `checking`, `checks_passed` or `checks_failed`, then `accepted` or `refused`. A release later becomes `revoked` if someone revokes the release. `hangar release status` shows the state, and `GET /v1/releases/{id}` gives the release in full.

## 4. Acceptance

TALOS Defence runs the acceptance checks on every release before the release runs for a tenant. Acceptance shows that the checks passed for the digests in the release. Acceptance does not change the release. The signature of the vendor and the digests stay the same.

The checks that must pass:

| Check | What it establishes |
|---|---|
| `vendor_signature` | The release id is signed by a release key registered to the vendor and not revoked. |
| `image_present` | The image and every companion are in the vendor's registry in Sydney under the digests the release names. |
| `sbom_present` | The software bill of materials the release names is stored, under its digest. |
| `envelope_request` | The envelope request is well formed: every destination is a valid name, every secret is bound to a declared destination, and every fallback is a declared destination. |
| `configuration_schema` | Every configuration key has a valid descriptor, and a destination-typed key names declared destinations only. |
| `runtime_range` | The release's runtime range covers a runtime Hangar supports. |
| `envelope_difference` | Hangar computes and records the difference from the release that each channel points at now (Section 5). |
| `self_test` | The self-test of the package ran on the CPU pool of this Hangar from the release itself, the return of the self-test verifies, and every expected output matched, by digest or by presence. |

The self-test is a job of the release with the `self_test` inputs of the package and the default configuration. It runs on the platform of the pool, after the preflight of the image, so Hangar cannot accept an image that cannot start on the pool. The return of the self-test shows the notice "This return is a self-test of a release before TALOS Defence accepted it."

When the checks pass, a TALOS Defence operator accepts the release. The acceptance record lists every check by name with the result, the pool and return digest of the self-test, and any widening. TALOS Defence signs the acceptance record with the release root at the key site. TALOS Defence uses the release root only to sign what TALOS Defence ships. No customer data and no permission for any job depends on the release root. A root signature signs the acceptance, never a day key.

Hangar refuses a release whose checks fail. The release shows the checks and the results.

## 5. Channels, pins, widening and revocation

A channel is a name that you choose (`stable`, `candidate`, or any other name). The channel points from a package to one accepted release.

```sh
hangar release channel hangar_patch_agent stable 5d1c09e2a7b4
```

A move (`POST /v1/packages/{name}/channels/{channel}`) takes an accepted release only, and every move is a record. A job gives a release id (a pin), or a package and a channel. When you submit a job, Hangar resolves the channel to a release id once, and records the release id. Every retry of the job uses that release, even when someone moves the channel.

### Widening

`hangar_cert::envelope::diff` compares two envelope requests and lists every widening, in a fixed order. A widening is one of these:

- a new destination, secret, data scope, companion or model route;
- a new fallback for a model route;
- a larger `vcpus`, `memory_mib` or `max_minutes`;
- a weaker failure policy: more retries, or resuming from a checkpoint;
- a person-present step removed.

At every job, TALOS Warrant compares the release with the baseline of the tenant. The baseline is the newest release of the package that the tenant has run. When the tenant has not run the package yet, the baseline is the first accepted release of the package. The same rule applies to the jobs of your own organisation, which have no tenant. An owner must approve the difference for the tenant before a release that asks for more than the baseline can run for that tenant. Until then, the jobs of the tenant on the channel use the newest earlier release of the channel that stays within the baseline, and the job shows this in `held_on`. Hangar refuses a job of the widening release by id for that tenant with `widening_not_approved`, and the error detail shows the approval that an owner can decide. A release that narrows the permissions needs no approval.

The acceptance record and each channel move also list a widening, against the newest accepted release of the package and against the release that the channel pointed at. Those lists show what changed. The baseline sets the approval rule.

`GET /v1/approvals` lists each pending widening approval. Each entry shows the release, the package and version, the channel, the tenant, the baseline and every item that the release adds. The approval record shows the baseline and the items, so the record shows what the owner approved. The approver is an owner of the vendor's organisation, who acts for the tenant on the website or through the approval routes. The approval record shows the person and the tenant. The next job after the approval resolves to the new release, and the return of that job contains the approval record. The compiled record of every job shows the baseline, and the return contains the baseline release. `hangar verify` recomputes the widening offline and checks the approval.

### Revocation

TALOS Defence or an owner of the vendor's organisation can revoke a release:

```sh
hangar release revoke 5d1c09e2a7b4 --reason "Wrong rule set"
```

A revocation is a record. Hangar holds a channel that points at a revoked release. Hangar refuses jobs on the channel with `channel_held` until you move the channel to an accepted release. Hangar never falls back to an earlier release by itself.

TALOS Defence signs the release revocation list with the release root and publishes the list at `GET /v1/releases/revoked`. When a vendor revokes a release of the vendor, Hangar holds the channels of the release at once. The signed list shows the revocation from the next signing. The list and the release key set (`GET /v1/releases/keyset`) are public, so a customer-controlled pool (offered from 2027) checks a release offline.

`GET /v1/releases/{id}/bundle` gives one release as a bundle that verifies offline: the signed release, the revocation list and the release key set. The vendor ships the image layers, as an OCI layout that the runtime checks by digest.

## 6. Site configuration

A package declares its configuration keys, and each pool and tenant sets the values. The same release id runs on every supported pool. One site differs from another in exactly three ways:

1. a declared configuration key;
2. the runtime version, inside the release's runtime range;
3. the pool identity on the return.

Nothing else selects behaviour by tenant or site. A change for one customer is a configuration key, or a new package version for every customer.

```
PUT /v1/config/hangar_patch_agent/hosted-cpu-syd
{"tenant": "tnt_0192f3a1c7d84e6b9a0c5d2e1f4b7a93", "values": {"test_timeout_seconds": 300}}
```

`PUT /v1/config/{package}/{pool}` sets values for the organisation, or for one tenant with `tenant`. A value for a tenant wins over a value for the organisation. Each set makes a new configuration version with its own digest, and each set is a record. `GET /v1/config/{package}/{pool}?tenant=` gives the current version.

The rules:

- A key the package does not declare is refused with `config_invalid`, and so is a value that does not match its descriptor.
- You must set a key that has no `default` for each pool. Hangar refuses a job on a pool where the key is not set with `config_missing`, and the error shows the key. `site_specific: true` marks a key whose value differs by pool, so the people who set up a pool know to set the key.
- Values are data. A destination-typed key selects one of the declared destinations of the package. No key gives an image, a secret value or a file to run.
- You set secrets by name through the secrets routes, and the gateway attaches them. Configuration never contains secrets.

TALOS Warrant compiles the digest of the configuration version into the envelope, and the return shows the digest. Two returns of one release on two sites show the same release id and different configuration digests.

## 7. Starting a job

```sh
hangar run --release 5d1c09e2a7b4 --tenant tnt_0192f3a1 \
    --input repository=sha384:9f2c41e0b37d --input rules='["path_traversal"]'
```

`POST /v1/release-jobs`, with an `Idempotency-Key`:

```json
{
  "package": "hangar_patch_agent",
  "channel": "stable",
  "tenant": "tnt_0192f3a1c7d84e6b9a0c5d2e1f4b7a93",
  "pool": "hosted-cpu-syd",
  "inputs": {
    "repository": {"artefact": "sha384:9f2c41e0b37d..."},
    "rules": {"value": ["path_traversal"]}
  },
  "narrowing": {"max_minutes": 10},
  "client_ref": "harbour-17/2026-10-23/1"
}
```

| Field | What it holds |
|---|---|
| `release`, or `package` and `channel` | The release id, or the package and the channel to resolve. |
| `tenant` | The tenant that the job serves. A request key that belongs to a tenant sends the same id in `Hangar-Tenant`. |
| `pool` | The pool, by name or id. The default is `hosted-cpu-syd`. |
| `inputs` | Each input by name: `{"artefact": "<digest>"}` for an artefact, `{"value": <JSON>}` for the rest. |
| `narrowing` | What the job gives up: the `destinations`, `secrets` and `model_routes` to keep, and lower `vcpus`, `memory_mib`, `max_minutes` and `retries`. Optional. |
| `client_ref` | Your own reference for the job, 1 to 128 characters, unique in your organisation and tenant. |

`client_ref` lasts longer than the 24-hour idempotency store. The same `client_ref` with the same body returns the existing job. A process that restarts after sending finds the job, and starts no second job. Hangar refuses the same `client_ref` with a different body with `client_ref_conflict`.

Jobs of a release start on `hosted-cpu-syd`. Each attempt runs on one fresh Graviton instance in AWS Sydney, for that attempt alone. Hangar terminates the instance when the attempt ends. From 2027, a customer-controlled pool runs releases from the Hangar runtime of the customer, in the customer's environment (Pools, Section 3).

### The compile

Before any instance launches, TALOS Warrant compiles the envelope, in this order:

1. The release: the id, or the channel resolved once, with the widening rule of Section 5.
2. The inputs, each against its descriptor.
3. The release's envelope request.
4. Your account policy: the egress list, and the release limits on vCPUs, memory, minutes, model routes and data scopes.
5. The policy of the tenant, which can only narrow your account policy.
6. The configuration version for the package, pool and tenant.
7. The narrowing of the job, which can only remove items.

Each step is an intersection. The envelope never exceeds what any one step allows. When a policy denies a required item, Hangar refuses the job with `envelope_denied`, and the error shows the item. When a policy denies an optional item, Hangar drops the item and writes the drop, with the reason, in the envelope. Hangar refuses a narrowing that adds anything with `narrowing_invalid`. Hangar compares names exactly, so a name that differs in case or by a final dot is a different name.

The compiled envelope is canonical CBOR with its own digest. Hangar writes the envelope as a record before the launch. The envelope lists the release, the pool, the tenant, the image and command, the companions, the destinations, the dropped items, the data scopes, the secrets by name, the model routes, the person-present steps, the compute, the failure policy, the configuration digest and the inputs by digest. The envelope also lists the digests of the request, the account policy, the tenant policy and the narrowing that the envelope came from.

### The window

Every envelope has a not-before time and a not-after time. The window is at most the `max_minutes` of one attempt plus 10 minutes to launch, multiplied by the number of attempts that the failure policy allows. Each attempt has its own hard stop: `max_minutes` plus the 10 minutes to launch, counted from the launch, and never later than the not-after time. At that stop, the authority for the attempt ends and the gateway closes. Hangar stops an attempt that still runs, and the attempt ends `interrupted` with the reason `attempt_time_limit`, or `window_end` at the not-after time. Hangar retries a stopped attempt while the window has room. No attempt starts after the not-after time, and a retry never extends the window.

### Enforcement

The worker receives the compiled envelope and the digest of the envelope with the orders for the attempt. The worker refuses to start an attempt when the image, command, companions, platform, inputs, values or configuration differ from the envelope (`envelope_mismatch`). Nothing runs, and the return records the refusal.

The gateway list for the attempt is the destinations in the envelope, and the gateway attaches the secrets in the envelope. A model route compiles to the primary destination and the declared fallback of the route. The application chooses which destination to call. The gateway refuses every other destination.

A person-present step sends an approval request to the phones of the approvers. A declared `credential_use` or `destination_first_use` step turns the approval on for the whole attempt. An `agent_action` step uses the proposal that the agent sends to the gateway. The approval card shows the sentence of Hangar, with the sentence of the package beside it.

## 8. States and outcomes

A job moves through seven states:

| State | What is happening |
|---|---|
| `compiling` | TALOS Warrant is compiling the envelope. |
| `queued` | The envelope is compiled, and the job waits for an attempt. |
| `launching` | A fresh instance is starting for the attempt. |
| `staging` | The worker checks the orders against the envelope, and stages the image and the inputs. |
| `running` | The application is running. |
| `sealing` | The worker seals and reports the outputs. |
| `returned` | The job has ended, and Hangar has signed the return or is signing the return. |

A returned job has one of five outcomes:

| Outcome | What it means |
|---|---|
| `succeeded` | The last attempt exited 0, the worker sealed every required output, each output passed its descriptor, and every output digest is in a record. |
| `failed` | The last attempt exited with another code, a required output failed its descriptor, or the worker refused the attempt. A job whose release is revoked before an attempt launches, or while it runs, also ends `failed`, with the reason `release_revoked`: Hangar launches no attempt and no retry of a revoked release, and stops the one running. |
| `cancelled` | Someone cancelled the job, or the tenant of the job was closed. Cancellation is final. |
| `interrupted` | The instance of the last attempt was lost, such as by a Spot reclamation, or the attempt reached its own time limit or the end of the window while the attempt ran, and no retry was left. |
| `outcome_unknown` | The application exited 0, but the worker did not report its outputs sealed, and no retry was left. Hangar cannot say that the outputs are whole, so Hangar never reports such a job as succeeded. |

The retry rule: Hangar retries an attempt on a fresh instance when the attempt was interrupted, failed with a non-zero exit, or did not report its outputs sealed. Hangar retries up to the `retries` of the failure policy, while the window has room. Hangar never retries an attempt that was refused with `envelope_mismatch`, an output that failed with `output_contract`, or an image that the preflight refused, because a retry would repeat the same failure.

Each attempt has its own states: `launching`, `staging`, `running`, `sealing` and `ended`. Each attempt also has its own outcome and reason.

`POST /v1/release-jobs/{id}/cancel` stops the attempt and closes the envelope. Cancellation is final. A cancelled job starts no other attempt.

## 9. Events

`GET /v1/release-jobs/{id}/events?after=<offset>&wait=<seconds>` gives the events of the job after the offset as newline-delimited JSON (`application/x-ndjson`). Every line shows the `offset` (from 1), `at` (RFC 3339 with the Melbourne offset), the envelope digest and the `kind`:

| Kind | Fields |
|---|---|
| `state` | `state`, and `outcome` once the job returns. |
| `compiled` | `destinations` and `dropped`, as counts. |
| `attempt` | `attempt`, `event` (`started` or `ended`), `pool`, `instance_type`, `zone`, `outcome` and `reason`. |
| `connection` | `attempt`, `destination`, `outcome` (`connected` or `refused`), `reason` and `connection_id`. |
| `approval` | `attempt`, `decision`, `proposal` and `approval_id`. |
| `artefact` | `attempt`, `name`, `digest`, `size_bytes` and `descriptor`. |

```
{"offset":7,"at":"2026-10-23T15:12:47+11:00","envelope":"sha384:c41e...","kind":"connection","attempt":1,"destination":"pypi.org","outcome":"connected"}
{"offset":8,"at":"2026-10-23T15:12:52+11:00","envelope":"sha384:c41e...","kind":"connection","attempt":1,"destination":"example.com","outcome":"refused","reason":"not_on_allowlist"}
{"kind":"heartbeat","at":"2026-10-23T15:13:07+11:00"}
```

Each call answers as soon as an event exists after the offset. Otherwise the call answers after at most 15 seconds with one heartbeat line, which has no offset. To continue, call again with the last offset that you read as `after`. Hangar sends the events since that offset, with no gap and no duplicate, however many times you call or reconnect. The last line of a returned job is `{"kind": "end", "state": "returned", "outcome": ...}`. With `wait=0`, the call reads once and answers at once. Reading a returned job gives the remaining events and the end line, and never starts an attempt.

The stream contains metadata only, never input bytes or output bytes. `hangar logs <job> --follow` prints the stream.

## 10. The return

Every job ends with a return. The return is a job certificate of kind 4, printed as "job certificate, application release". The issuer of the key set of the pool signs the return. On the pools of Hangar, the issuer shown on each return is TALOS Defence Pty Ltd. Hangar signs a return after the machine is wiped, usually within 15 minutes after the last attempt seals. If you ask for the return before then, the API answers `return_pending`.

| Call | What it gives |
|---|---|
| `GET /v1/release-jobs/{id}/return` | The return, as CBOR. |
| `GET /v1/release-jobs/{id}/artefacts` | Each artefact with its digest, size, media type, descriptor result, attempt and `content` link. |
| `GET /v1/objects/{digest}/content` | The bytes of an artefact, checked against the digest, for objects of at most 256 MiB. |
| `GET /v1/certificates/{id}/report` | The report of the verifier on the return, checked with the published key sets. |

The return lists:

- the vendor organisation and the tenant;
- the package and version, the release id, the package manifest digest, the image and companion digests and the SBOM digest;
- the signature of the vendor with the key registration record, and the acceptance of TALOS Defence with its record;
- the baseline release that the widening is measured against, when the baseline is another release, and any widening approval record;
- the configuration version digest;
- the envelope as compiled, in full, with the digests it came from;
- each attempt: the pool identity, the instance type and id, the zone, the purchase, the times, the reset records, the outcome and the reason;
- the inputs and outputs by digest, size and media type, with each output's descriptor result;
- each connection and refusal by destination name, and the use of each model route: a route counts as used through the primary when a connection to the primary destination was made, and through the fallback when a connection to the declared fallback was made;
- the approvals;
- the runtime version.

A return contains no input or output bytes, no configuration value and no secret value.

### What hangar verify checks

```sh
hangar verify 0192f3b7
hangar verify 0192f3b7.cert --release-keys release_keyset.cbor
```

`hangar verify` runs every check of the job certificate. For a return, it adds the `release` check:

"The release is signed by its vendor and accepted by TALOS Defence, the compiled envelope lies within the release's request, and every connection and output stays within it."

In order:

1. The release manifest decodes, and the id of the manifest matches the return.
2. The signature of the vendor verifies over the release id with the key in the included registration record.
3. A root in the published release key set of TALOS Defence signed the acceptance, never a day key. The acceptance shows the release and the vendor key.
4. The compiled envelope lies within the envelope request of the release, which the verifier recomputes. The envelope shows the release and the pool.
5. The destination of every connection is in the envelope, and the destination of every model route is in the envelope.
6. Every output digest is in an included record. A `succeeded` outcome has every output passed.

The `hangar` command line includes the release key set that TALOS Defence publishes. `--release-keys FILE` gives the command line another key set. The customer's own keys sign a return from a customer-controlled pool (offered from 2027). To check the return, run `hangar verify --issuer-keys <customer key set>`.

## 11. Errors

Every error has three parts, from the catalogue in `docs/api/errors.json`: what failed, the next step, and what to do if that fails. The codes these routes return:

| Code | Status | When |
|---|---|---|
| `package_manifest_invalid` | 422 | The manifest has an unknown field, a bad name or version, or an output that is not an artefact. |
| `image_not_by_digest` | 422 | The manifest names an image by tag. |
| `package_version_exists` | 409 | The package already has that version. |
| `package_not_found` | 404 | No package of that name in your organisation. |
| `package_recipe_not_offered` | 422 | The image is not a pushed image named by digest. |
| `platform_not_offered` | 422 | The pool runs images for another platform. |
| `placement_not_offered` | 422 | The job names a placement other than `hosted-cpu-syd`. |
| `pool_not_found` | 404 | No pool of that name or id. |
| `pool_runs_locally` | 409 | The pool is customer-controlled; jobs start from its own runtime. |
| `data_scope_not_offered` | 422 | A data scope other than `run_inputs` or none. |
| `envelope_request_invalid` | 422 | The envelope request is not valid; the item is named. |
| `input_contract` | 422 | An input does not match its descriptor. |
| `output_contract` | 422 | An artefact does not match its descriptor. |
| `vendor_not_enabled` | 403 | Your organisation is not set up as a vendor. |
| `vendor_key_invalid` | 422 | The release key is not a valid ML-DSA-87 and P-256 pair. |
| `vendor_key_not_found` | 404 | No release key of that name in your organisation. |
| `release_not_found` | 404 | No release with that id in your organisation. |
| `release_draft_mismatch` | 422 | The manifest differs from the package version Hangar holds. |
| `release_signature_invalid` | 422 | The signature does not verify with a registered release key. |
| `release_sequence_stale` | 409 | The sequence number is not the next one. |
| `release_not_accepted` | 409 | TALOS Defence has not accepted the release. |
| `release_revoked` | 409 | The release was revoked. |
| `channel_held` | 409 | The channel's release was revoked. |
| `channel_not_found` | 404 | No channel of that name for the package. |
| `widening_not_approved` | 409 | The release asks for more than an owner approved for the tenant. |
| `envelope_denied` | 422 | Your policy denies an item the release requires. |
| `narrowing_invalid` | 422 | The narrowing adds an item. |
| `config_missing` | 422 | A configuration value the job needs is not set. |
| `config_invalid` | 422 | A configuration key is undeclared, or a value does not match its key. |
| `client_ref_conflict` | 409 | The client reference belongs to a job with a different request. |
| `release_job_not_found` | 404 | No job with that id in your organisation. |
| `release_job_not_cancellable` | 409 | The job has returned. |
| `return_pending` | 409 | The return is not signed yet. It is retryable. |
| `envelope_mismatch` | 409 | The worker refused orders that differ from the envelope. |
| `job_token_invalid` | 401 | The job token is not valid. The job token has expired, or Hangar revoked the job token with its job, tenant or key. |
| `job_token_scope` | 403 | A job token works only for its own job, with the capabilities that the job token has. |
| `object_too_large_for_content` | 413 | The object is larger than one content answer. |
| `tenant_suspended` | 409 | The tenant is suspended. |
| `tenant_mismatch` | 403 | The key or object belongs to another tenant. |
| `revocation_list_stale` | 409 | The pool's revocation list is older than its policy allows. |
